eids, KeyType keyType, Layer layer) {
+ return new CacheResult(State.HIT, eids, keyType, layer);
+ }
+
+ public static CacheResult negative(KeyType keyType, Layer layer) {
+ return new CacheResult(State.NEGATIVE, List.of(), keyType, layer);
+ }
+
+ public static CacheResult inProgress(KeyType keyType, Layer layer) {
+ return new CacheResult(State.IN_PROGRESS, List.of(), keyType, layer);
+ }
+
+ public static CacheResult miss() {
+ return MISS;
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheTtlPolicy.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheTtlPolicy.java
new file mode 100644
index 00000000000..b1e571dd682
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheTtlPolicy.java
@@ -0,0 +1,41 @@
+package org.prebid.server.hooks.modules.intentiq.identity.cache;
+
+/**
+ * TTL policy for cached identity entries. The IntentIQ API {@code cttl} (or the configured default
+ * when absent) always wins, but is capped by a per-{@link KeyType} ceiling — we cache the volatile
+ * resolved eids, not the stable cookie mapping, so ceilings are upper bounds only and deliberately
+ * far shorter than the IntentIQ backend's mapping TTLs. Negative (unresolvable) entries and the
+ * in-progress marker each use a separate short TTL.
+ */
+public record CacheTtlPolicy(long defaultTtlMs,
+ long firstPartyCeilingMs,
+ long thirdPartyCeilingMs,
+ long deviceCeilingMs,
+ long negativeTtlMs,
+ long inProgressTtlMs) {
+ public long ceilingFor(KeyType type) {
+ return switch (type) {
+ case FIRST_PARTY -> firstPartyCeilingMs;
+ case THIRD_PARTY -> thirdPartyCeilingMs;
+ case DEVICE -> deviceCeilingMs;
+ };
+ }
+
+ /**
+ * Effective positive TTL for a key: {@code min(cttl-or-default, ceiling(type))}.
+ */
+ public long effectiveTtlMs(KeyType type, long cttlMs) {
+ final long base = cttlMs > 0 ? cttlMs : defaultTtlMs;
+ return Math.min(base, ceilingFor(type));
+ }
+
+ /**
+ * Suppression TTL for a negative (unresolvable) entry. On an empty/invalid response the IntentIQ
+ * backend signals how long to suppress re-querying this user via {@code cttl}; honor it when present
+ * (bounded by the first-party ceiling as a safety cap against absurd values), else fall back to the
+ * configured default negative TTL.
+ */
+ public long negativeTtlMs(long cttlMs) {
+ return cttlMs > 0 ? Math.min(cttlMs, firstPartyCeilingMs) : negativeTtlMs;
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCache.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCache.java
new file mode 100644
index 00000000000..1027acc34d5
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCache.java
@@ -0,0 +1,269 @@
+package org.prebid.server.hooks.modules.intentiq.identity.cache;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
+import com.iab.openrtb.request.Eid;
+import io.vertx.core.CompositeFuture;
+import io.vertx.core.Future;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.jspecify.annotations.NonNull;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.log.Logger;
+import org.prebid.server.log.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Dual-layer, multi-key (alias) cache for resolved eids: Caffeine (L1, in-process) backed by a
+ * pluggable {@link IdentityStore} (L2, shared; Redis by default).
+ *
+ * A request yields an ordered list of {@link CacheKey}s (one per first-party id present). On read,
+ * the highest-priority key with a live entry wins and that entry is back-filled under every other key
+ * that missed, so the alias graph grows over time and a later request carrying any of those ids hits
+ * (strategy B). On a full miss the caller fetches once and writes the entry under all keys.
+ *
+ *
Entries carry the IntentIQ {@code cttl} capped by a per-{@link KeyType} ceiling (see
+ * {@link CacheTtlPolicy}). Unresolvable ids are cached as a short-lived negative sentinel so they do
+ * not re-hit the upstream API. L2 failures are swallowed (fail-open) so the auction can fall through
+ * to a live call. Differing resolutions are never merged — only the single winning entry propagates.
+ */
+public class IdentityCache {
+
+ private static final Logger logger = LoggerFactory.getLogger(IdentityCache.class);
+
+ private final Cache local;
+ private final IdentityStore store;
+ private final JacksonMapper mapper;
+ private final CacheTtlPolicy ttlPolicy;
+ private final IntentiqIdentityMetrics metrics;
+
+ public IdentityCache(long maxSize, CacheTtlPolicy ttlPolicy, IdentityStore store, JacksonMapper mapper,
+ IntentiqIdentityMetrics metrics) {
+ this.local = Caffeine.newBuilder()
+ .maximumSize(maxSize)
+ .expireAfter(new CacheEntryExpiry())
+ .recordStats()
+ .build();
+ this.ttlPolicy = Objects.requireNonNull(ttlPolicy);
+ this.store = Objects.requireNonNull(store);
+ this.mapper = Objects.requireNonNull(mapper);
+ this.metrics = Objects.requireNonNull(metrics);
+ // L1 capacity gauges: current size (vs cache-max-size) and cumulative evictions.
+ metrics.registerL1Gauges(local::estimatedSize, () -> local.stats().evictionCount());
+ }
+
+ public Future get(List keys) {
+ if (keys == null || keys.isEmpty()) {
+ return Future.succeededFuture(CacheResult.miss());
+ }
+
+ // L1 sweep in priority order; Caffeine evicts expired entries, so a present entry is live.
+ // A resolved entry always wins; an in-progress marker is a fallback that short-circuits the
+ // L2 probe (this instance already knows a call is in flight) without firing a duplicate.
+ KeyType inProgressType = null;
+ for (int i = 0; i < keys.size(); i++) {
+ final CacheEntry entry = l1Get(keys.get(i).key());
+ if (entry == null) {
+ continue;
+ }
+ if (entry.isInProgress()) {
+ if (inProgressType == null) {
+ inProgressType = keys.get(i).type();
+ }
+ continue;
+ }
+ backfill(keys, i, entry);
+ return Future.succeededFuture(toResult(entry, keys.get(i).type(), CacheResult.Layer.L1));
+ }
+ if (inProgressType != null) {
+ return Future.succeededFuture(CacheResult.inProgress(inProgressType, CacheResult.Layer.L1));
+ }
+
+ // Full L1 miss: probe all keys in L2 concurrently. Prefer the highest-priority resolved entry;
+ // fall back to an in-progress marker only if no resolved entry is found under any key.
+ return l2GetAll(keys).map(entries -> {
+ KeyType l2InProgressType = null;
+ for (int i = 0; i < entries.size(); i++) {
+ final CacheEntry entry = entries.get(i);
+ if (entry == null) {
+ continue;
+ }
+ if (entry.isInProgress()) {
+ if (l2InProgressType == null) {
+ l1Put(keys.get(i).key(), entry);
+ l2InProgressType = keys.get(i).type();
+ }
+ continue;
+ }
+ l1Put(keys.get(i).key(), entry);
+ backfill(keys, i, entry);
+ return toResult(entry, keys.get(i).type(), CacheResult.Layer.L2);
+ }
+ return l2InProgressType != null
+ ? CacheResult.inProgress(l2InProgressType, CacheResult.Layer.L2)
+ : CacheResult.miss();
+ });
+ }
+
+ public void put(List keys, List eids, long cttlMs) {
+ for (CacheKey key : keys) {
+ final long ttl = ttlPolicy.effectiveTtlMs(key.type(), cttlMs);
+ writeBoth(key.key(), CacheEntry.of(eids, false, false, System.currentTimeMillis() + ttl), ttl);
+ }
+ }
+
+ public void putNegative(List keys, long cttlMs) {
+ final long ttl = ttlPolicy.negativeTtlMs(cttlMs);
+ for (CacheKey key : keys) {
+ writeBoth(key.key(), CacheEntry.of(List.of(), true, false, System.currentTimeMillis() + ttl), ttl);
+ }
+ }
+
+ /**
+ * Mark a resolution as in flight: write an IN_PROGRESS sentinel under every key with the short
+ * in-progress TTL, so a concurrent request for the same id reads it and skips firing a duplicate
+ * upstream call. Overwritten by {@link #put} / {@link #putNegative} when the call completes; if the
+ * call never completes the marker simply expires.
+ */
+ public void putInProgress(List keys) {
+ final long ttl = ttlPolicy.inProgressTtlMs();
+ for (CacheKey key : keys) {
+ writeBoth(key.key(), CacheEntry.of(List.of(), false, true, System.currentTimeMillis() + ttl), ttl);
+ }
+ }
+
+ /** Propagate the winning entry under every other key that missed, capped by each key's ceiling. */
+ private void backfill(List keys, int hitIndex, CacheEntry hit) {
+ final long remaining = hit.getExp() - System.currentTimeMillis();
+ if (remaining <= 0) {
+ return;
+ }
+
+ for (int i = 0; i < keys.size(); i++) {
+ if (i == hitIndex) {
+ continue;
+ }
+ final CacheKey key = keys.get(i);
+ if (l1Get(key.key()) != null) {
+ continue;
+ }
+ final long ttl = Math.min(remaining, ttlPolicy.ceilingFor(key.type()));
+ writeBoth(key.key(), CacheEntry.of(hit.getEids(), hit.isNegative(), hit.isInProgress(),
+ System.currentTimeMillis() + ttl), ttl);
+ }
+ }
+
+ private void writeBoth(String key, CacheEntry entry, long ttlMs) {
+ l1Put(key, entry);
+ final long startNanos = System.nanoTime();
+ store.put(key, mapper.encodeToString(entry), ttlMs)
+ .onComplete(ignored -> metrics.l2PutLatency(System.nanoTime() - startNanos))
+ .onFailure(throwable -> {
+ metrics.l2PutError();
+ logger.warn("IntentIQ identity cache L2 PUT failed", throwable);
+ });
+ }
+
+ // L1 (Caffeine) is in-process and effectively never fails, but wrap it so a pathological failure
+ // (e.g. expiry/weigher throwing, OOM) is counted and swallowed (fail open) rather than aborting.
+ private void l1Put(String key, CacheEntry entry) {
+ try {
+ local.put(key, entry);
+ } catch (RuntimeException e) {
+ metrics.l1PutError();
+ logger.warn("IntentIQ identity cache L1 PUT failed", e);
+ }
+ }
+
+ private CacheEntry l1Get(String key) {
+ try {
+ return local.getIfPresent(key);
+ } catch (RuntimeException e) {
+ metrics.l1GetError();
+ logger.warn("IntentIQ identity cache L1 GET failed", e);
+ return null;
+ }
+ }
+
+ private Future> l2GetAll(List keys) {
+ final List> futures = new ArrayList<>();
+ for (CacheKey key : keys) {
+ final long startNanos = System.nanoTime();
+ futures.add(store.get(key.key())
+ .onComplete(ignored -> metrics.l2GetLatency(System.nanoTime() - startNanos))
+ .map(this::decodeValid)
+ .otherwise(throwable -> {
+ // L2 read failed: we fall through to a live API call (fail open). Count it so the
+ // otherwise-swallowed failure is visible for alerting.
+ metrics.l2GetError();
+ logger.warn("IntentIQ identity cache L2 GET failed", throwable);
+ return null;
+ }));
+ }
+ return Future.join(futures).map(CompositeFuture::list);
+ }
+
+ private CacheEntry decodeValid(String value) {
+ if (value == null) {
+ return null;
+ }
+ final CacheEntry entry = mapper.decodeValue(value, CacheEntry.class);
+ if (entry == null || entry.getExp() <= System.currentTimeMillis()) {
+ return null;
+ }
+ return entry;
+ }
+
+ private static CacheResult toResult(CacheEntry entry, KeyType keyType, CacheResult.Layer layer) {
+ if (entry.isInProgress()) {
+ return CacheResult.inProgress(keyType, layer);
+ }
+ return entry.isNegative()
+ ? CacheResult.negative(keyType, layer)
+ : CacheResult.hit(entry.getEids(), keyType, layer);
+ }
+
+ @Data
+ @NoArgsConstructor
+ @AllArgsConstructor(staticName = "of")
+ static class CacheEntry {
+
+ List eids;
+
+ boolean negative;
+
+ boolean inProgress;
+
+ long exp;
+ }
+
+ private static class CacheEntryExpiry implements Expiry {
+
+ @Override
+ public long expireAfterCreate(@NonNull String key, @NonNull CacheEntry value, long currentTime) {
+ return remainingNanos(value);
+ }
+
+ @Override
+ public long expireAfterUpdate(@NonNull String key, @NonNull CacheEntry value,
+ long currentTime, long currentDuration) {
+ return remainingNanos(value);
+ }
+
+ @Override
+ public long expireAfterRead(@NonNull String key, @NonNull CacheEntry value,
+ long currentTime, long currentDuration) {
+ return currentDuration;
+ }
+
+ private static long remainingNanos(CacheEntry value) {
+ return Math.max(0, value.getExp() - System.currentTimeMillis()) * 1_000_000L;
+ }
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityStore.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityStore.java
new file mode 100644
index 00000000000..dd589e02629
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityStore.java
@@ -0,0 +1,16 @@
+package org.prebid.server.hooks.modules.intentiq.identity.cache;
+
+import io.vertx.core.Future;
+
+/**
+ * Generic, backend-agnostic key/value store used as the shared (L2) layer of {@link IdentityCache}.
+ * The default implementation is Redis ({@link RedisIdentityStore}); a partner can provide a different
+ * backend by supplying another implementation. Values are opaque strings (the cache handles
+ * serialization), so the store stays decoupled from the eid model.
+ */
+public interface IdentityStore {
+
+ Future get(String key);
+
+ Future put(String key, String value, long ttlMs);
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/KeyType.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/KeyType.java
new file mode 100644
index 00000000000..4a3517c2b14
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/KeyType.java
@@ -0,0 +1,14 @@
+package org.prebid.server.hooks.modules.intentiq.identity.cache;
+
+/**
+ * Classifies a cache key by the kind of identifier it carries, so the TTL ceiling can be applied
+ * per id class (see {@link CacheTtlPolicy}). First-party ids are treated as longer-lived than
+ * third-party / probabilistic ones. Note {@code intentiq.com} is treated as {@link #THIRD_PARTY},
+ * matching how the IntentIQ backend classifies the IntentIQ cookie id.
+ */
+public enum KeyType {
+
+ FIRST_PARTY,
+ THIRD_PARTY,
+ DEVICE
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStore.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStore.java
new file mode 100644
index 00000000000..5ee618c3ac3
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStore.java
@@ -0,0 +1,55 @@
+package org.prebid.server.hooks.modules.intentiq.identity.cache;
+
+import io.vertx.core.Future;
+import io.vertx.redis.client.RedisAPI;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+/**
+ * Redis-backed {@link IdentityStore} (the default L2 backend). Stores values with a per-entry TTL
+ * via {@code SET key value PX }.
+ */
+@RequiredArgsConstructor
+public class RedisIdentityStore implements IdentityStore {
+
+ private static final String EVICTED_KEYS_FIELD = "evicted_keys:";
+
+ private final RedisAPI redis;
+
+ @Override
+ public Future get(String key) {
+ return redis.get(key).map(response -> response != null ? response.toString() : null);
+ }
+
+ @Override
+ public Future put(String key, String value, long ttlMs) {
+ return redis.set(List.of(key, value, "PX", Long.toString(ttlMs))).mapEmpty();
+ }
+
+ /** Current key count of the selected DB ({@code DBSIZE}). Instance-wide, not module-scoped. */
+ public Future dbSize() {
+ return redis.dbsize().map(response -> response != null ? response.toLong() : 0L);
+ }
+
+ /** Cumulative {@code evicted_keys} from {@code INFO stats}. Instance-wide. */
+ public Future evictedKeys() {
+ return redis.info(List.of("stats")).map(RedisIdentityStore::parseEvictedKeys);
+ }
+
+ private static Long parseEvictedKeys(io.vertx.redis.client.Response response) {
+ if (response == null) {
+ return 0L;
+ }
+ for (final String line : response.toString().split("\\r?\\n")) {
+ if (line.startsWith(EVICTED_KEYS_FIELD)) {
+ try {
+ return Long.parseLong(line.substring(EVICTED_KEYS_FIELD.length()).trim());
+ } catch (NumberFormatException e) {
+ return 0L;
+ }
+ }
+ }
+ return 0L;
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporter.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporter.java
new file mode 100644
index 00000000000..b03748be73f
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporter.java
@@ -0,0 +1,53 @@
+package org.prebid.server.hooks.modules.intentiq.identity.cache;
+
+import io.vertx.core.Vertx;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.log.Logger;
+import org.prebid.server.log.LoggerFactory;
+
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Periodically polls Redis (L2) {@code DBSIZE} and {@code INFO stats} {@code evicted_keys} and exposes
+ * them as the global {@code l2.size} / {@code l2.eviction} gauges. Redis stats are asynchronous and
+ * instance-wide, so (unlike Caffeine's in-process L1 counters) they can't be read inside a synchronous
+ * gauge — this caches the latest poll into atomics the gauges read. Both values are Redis-instance-wide,
+ * not module-scoped.
+ */
+public class RedisStatsReporter {
+
+ private static final Logger logger = LoggerFactory.getLogger(RedisStatsReporter.class);
+
+ private final RedisIdentityStore store;
+ private final Vertx vertx;
+ private final long pollIntervalMs;
+ private final AtomicLong size = new AtomicLong();
+ private final AtomicLong evictions = new AtomicLong();
+
+ public RedisStatsReporter(RedisIdentityStore store,
+ Vertx vertx,
+ IntentiqIdentityMetrics metrics,
+ long pollIntervalMs) {
+ this.store = Objects.requireNonNull(store);
+ this.vertx = Objects.requireNonNull(vertx);
+ this.pollIntervalMs = pollIntervalMs;
+ metrics.registerL2Gauges(size::get, evictions::get);
+ }
+
+ /** Poll once immediately, then on a fixed interval. Returns this for fluent wiring. */
+ public RedisStatsReporter start() {
+ poll();
+ vertx.setPeriodic(pollIntervalMs, id -> poll());
+ return this;
+ }
+
+ private void poll() {
+ store.dbSize()
+ .onSuccess(size::set)
+ .onFailure(t -> logger.warn("IntentIQ identity L2 DBSIZE poll failed", t));
+ store.evictedKeys()
+ .onSuccess(evictions::set)
+ .onFailure(t -> logger.warn("IntentIQ identity L2 evicted_keys poll failed", t));
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfig.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfig.java
new file mode 100644
index 00000000000..87183d02546
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfig.java
@@ -0,0 +1,146 @@
+package org.prebid.server.hooks.modules.intentiq.identity.config;
+
+import com.codahale.metrics.MetricRegistry;
+import io.vertx.core.Vertx;
+import io.vertx.redis.client.Redis;
+import io.vertx.redis.client.RedisAPI;
+import io.vertx.redis.client.RedisOptions;
+import org.apache.commons.lang3.StringUtils;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheTtlPolicy;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityCache;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityStore;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.RedisIdentityStore;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.RedisStatsReporter;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.NoopIntentiqIdentityMetrics;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.CacheProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.RedisProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityAuctionResponseHook;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityModule;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityProcessedAuctionRequestHook;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.FirstPartyKeyExtractor;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.json.JsonMerger;
+import org.prebid.server.json.ObjectMapperProvider;
+import org.prebid.server.vertx.httpclient.HttpClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+@ConditionalOnProperty(prefix = "hooks." + IntentiqIdentityModule.CODE, name = "enabled", havingValue = "true")
+@Configuration
+public class IntentiqIdentityConfig {
+
+ // How often the L2 (Redis) size/eviction gauges are refreshed from DBSIZE / INFO.
+ private static final long REDIS_STATS_POLL_MS = 30_000L;
+
+ @Bean
+ @ConfigurationProperties(prefix = "hooks.modules." + IntentiqIdentityModule.CODE)
+ IntentiqIdentityProperties intentiqIdentityProperties() {
+ return new IntentiqIdentityProperties();
+ }
+
+ @Bean
+ ConfigResolver intentiqIdentityConfigResolver(JsonMerger jsonMerger, IntentiqIdentityProperties properties) {
+ return new ConfigResolver(ObjectMapperProvider.mapper(), jsonMerger, properties);
+ }
+
+ /**
+ * Default L2 store; can override caching by supplying your own {@link IdentityStore} bean.
+ */
+ @Bean
+ @ConditionalOnMissingBean(IdentityStore.class)
+ @ConditionalOnProperty(prefix = "hooks.modules." + IntentiqIdentityModule.CODE + ".cache",
+ name = "enabled", havingValue = "true")
+ IdentityStore intentiqIdentityStore(IntentiqIdentityProperties properties, Vertx vertx) {
+ return new RedisIdentityStore(createRedisApi(properties.getRedis(), vertx));
+ }
+
+ @Bean
+ @ConditionalOnProperty(prefix = "hooks.modules." + IntentiqIdentityModule.CODE + ".cache",
+ name = "enabled", havingValue = "true")
+ IdentityCache intentiqIdentityCache(IntentiqIdentityProperties properties,
+ IdentityStore identityStore,
+ JacksonMapper mapper,
+ IntentiqIdentityMetrics metrics) {
+ final CacheProperties cache = properties.getCache();
+ final CacheTtlPolicy ttlPolicy = new CacheTtlPolicy(
+ cache.getTtlseconds() * 1000L,
+ cache.getTtlCeilingFirstPartySeconds() * 1000L,
+ cache.getTtlCeilingThirdPartySeconds() * 1000L,
+ cache.getTtlCeilingDeviceSeconds() * 1000L,
+ cache.getNegativeTtlSeconds() * 1000L,
+ cache.getInProgressTtlSeconds() * 1000L);
+
+ return new IdentityCache(properties.getCacheMaxSize(), ttlPolicy, identityStore, mapper, metrics);
+ }
+
+ /**
+ * Polls Redis (L2) DBSIZE / evicted_keys into the {@code l2.size} / {@code l2.eviction} gauges.
+ * Only when the L2 store is the default Redis backend (a custom {@link IdentityStore} has no such
+ * stats). Returns {@code null} otherwise — Spring simply does not register the bean.
+ */
+ @Bean
+ @ConditionalOnProperty(prefix = "hooks.modules." + IntentiqIdentityModule.CODE + ".cache",
+ name = "enabled", havingValue = "true")
+ RedisStatsReporter intentiqIdentityRedisStatsReporter(IdentityStore identityStore,
+ Vertx vertx,
+ IntentiqIdentityMetrics metrics) {
+ return identityStore instanceof RedisIdentityStore redisStore
+ ? new RedisStatsReporter(redisStore, vertx, metrics, REDIS_STATS_POLL_MS).start()
+ : null;
+ }
+
+ @Bean
+ @ConditionalOnProperty(prefix = "hooks.modules." + IntentiqIdentityModule.CODE,
+ name = "metrics-enabled", havingValue = "true", matchIfMissing = true)
+ IntentiqIdentityMetrics intentiqIdentityMetrics(MetricRegistry metricRegistry) {
+ return new IntentiqIdentityMetrics(metricRegistry);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean(IntentiqIdentityMetrics.class)
+ IntentiqIdentityMetrics noopIntentiqIdentityMetrics() {
+ return new NoopIntentiqIdentityMetrics();
+ }
+
+ @Bean
+ IntentiqIdentityModule intentiqIdentityModule(ConfigResolver configResolver,
+ HttpClient httpClient,
+ JacksonMapper mapper,
+ IntentiqIdentityMetrics metrics,
+ IntentiqIdentityProperties properties,
+ @Autowired(required = false) IdentityCache identityCache) {
+ final FirstPartyKeyExtractor keyExtractor =
+ new FirstPartyKeyExtractor(properties.getCache().getMaxKeys());
+
+ return new IntentiqIdentityModule(List.of(
+ new IntentiqIdentityProcessedAuctionRequestHook(
+ configResolver, httpClient, mapper, identityCache, keyExtractor, metrics),
+ new IntentiqIdentityAuctionResponseHook(configResolver, httpClient, mapper, metrics)));
+ }
+
+ private static RedisAPI createRedisApi(RedisProperties redis, Vertx vertx) {
+ if (redis == null || StringUtils.isBlank(redis.getHost())) {
+ throw new IllegalArgumentException("hooks.modules." + IntentiqIdentityModule.CODE
+ + ".redis.host is required when cache is enabled");
+ }
+ if (redis.getPort() == null) {
+ throw new IllegalArgumentException("hooks.modules." + IntentiqIdentityModule.CODE
+ + ".redis.port is required when cache is enabled");
+ }
+
+ final String credentials = StringUtils.isNotBlank(redis.getPassword()) ? ":" + redis.getPassword() + "@" : "";
+ final RedisOptions options = new RedisOptions()
+ .setConnectionString("redis://" + credentials + redis.getHost() + ":" + redis.getPort());
+
+ return RedisAPI.api(Redis.createClient(vertx, options));
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetrics.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetrics.java
new file mode 100644
index 00000000000..2ed890e73ff
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetrics.java
@@ -0,0 +1,183 @@
+package org.prebid.server.hooks.modules.intentiq.identity.metric;
+
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.MetricRegistry;
+import org.apache.commons.lang3.StringUtils;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityModule;
+
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
+
+/**
+ * Module-specific counters for dashboards, namespaced under {@code modules.module..custom.*}
+ * (grouped with the framework's {@code modules.module..*} tree, see the README Metrics section).
+ * Call/success/failure/execution-time are already emitted by the hook framework and are not duplicated here.
+ *
+ * Each metric is suffixed with the partner's {@code dpi} (the partner-facing data-provider id, never an
+ * internal backend id) as {@code _}, following the per-partner naming convention so the same
+ * Grafana per-partner templating applies. The suffix is omitted when no dpi is configured.
+ */
+public class IntentiqIdentityMetrics {
+
+ private static final String PREFIX = "modules.module." + IntentiqIdentityModule.CODE + ".custom.";
+
+ private final MetricRegistry metricRegistry;
+
+ public IntentiqIdentityMetrics(MetricRegistry metricRegistry) {
+ this.metricRegistry = Objects.requireNonNull(metricRegistry);
+ }
+
+ /** For the no-op subclass wired when {@code metrics-enabled} is false; never touches the registry. */
+ protected IntentiqIdentityMetrics() {
+ this.metricRegistry = null;
+ }
+
+ /** Positive entry served from cache; {@code layer} is {@code l1} (Caffeine) or {@code l2} (Redis). */
+ public void cacheHit(String layer, String keyType, String dpi) {
+ inc("cache." + layer + ".hit." + keyType, dpi);
+ }
+
+ /** Full miss — neither L1 nor L2 had the id; the API is called. Not layer-specific. */
+ public void cacheMiss(String keyType, String dpi) {
+ inc("cache.miss." + keyType, dpi);
+ }
+
+ public void cacheNegativeHit(String layer, String keyType, String dpi) {
+ inc("cache." + layer + ".negative.hit." + keyType, dpi);
+ }
+
+ public void cacheInProgress(String layer, String keyType, String dpi) {
+ inc("cache." + layer + ".in_progress." + keyType, dpi);
+ }
+
+ public void apiSuccess(String dpi) {
+ inc("api.success", dpi);
+ }
+
+ public void apiError(String dpi) {
+ inc("api.error", dpi);
+ }
+
+ public void enriched(String dpi) {
+ inc("enriched", dpi);
+ }
+
+ /** The resolution produced no eids (no match); pairs with {@link #enriched} for a match rate. */
+ public void eidsNone(String dpi) {
+ inc("eids.none", dpi);
+ }
+
+ /** Resolution skipped before any API call because no api-endpoint is configured for the partner. */
+ public void skipNoEndpoint(String dpi) {
+ inc("skip.no_endpoint", dpi);
+ }
+
+ public void terminationCause(long tc, String dpi) {
+ if (tc >= 0 && tc < 200) {
+ inc("tc." + tc, dpi);
+ }
+ }
+
+ /** Records the latency of a single identity-resolution API call, regardless of outcome. */
+ public void apiLatency(long timeNanos, String dpi) {
+ time("api.latency", dpi, timeNanos);
+ }
+
+ /**
+ * Wall-clock latency of the whole module flow within one auction: from the enrich hook
+ * (processed-auction-request) entry to the auction-response hook (bid release). Recorded once
+ * per auction the module participated in.
+ */
+ public void flowLatency(long timeNanos, String dpi) {
+ time("flow.latency", dpi, timeNanos);
+ }
+
+ public void impressionReported(String dpi) {
+ inc("impression.reported", dpi);
+ }
+
+ public void impressionError(String dpi) {
+ inc("impression.error", dpi);
+ }
+
+ // --- Backpressure / capacity (shared L1/L2 health) ---------------------------------------------
+ // Caffeine (L1) and Redis (L2) are process-wide singletons shared across all partners, so their
+ // saturation is not attributable to a single dpi. These are emitted WITHOUT the _ suffix
+ // (global), unlike the per-partner business counters above; this also keeps their cardinality at
+ // exactly one series each. See the README Metrics section.
+
+ /** An L1 (Caffeine) read threw; treated as a miss for that key. Should be ~never. */
+ public void l1GetError() {
+ inc("l1.get.error", null);
+ }
+
+ /** An L1 (Caffeine) write threw; the entry did not land in L1. Should be ~never. */
+ public void l1PutError() {
+ inc("l1.put.error", null);
+ }
+
+ /** Latency of a single L2 (Redis) GET, recorded on every probe regardless of outcome. */
+ public void l2GetLatency(long timeNanos) {
+ time("l2.get.latency", null, timeNanos);
+ }
+
+ /** Latency of a single L2 (Redis) PUT, recorded on every write regardless of outcome. */
+ public void l2PutLatency(long timeNanos) {
+ time("l2.put.latency", null, timeNanos);
+ }
+
+ /**
+ * An L2 GET failed/timed out (connection refused, pool exhaustion, command timeout). The cache
+ * fails open — it falls through to a live API call — so without this counter the failure is
+ * invisible. Each fall-through-on-read is one increment.
+ */
+ public void l2GetError() {
+ inc("l2.get.error", null);
+ }
+
+ /** An L2 PUT failed/timed out; the entry still lives in L1 but did not reach the shared store. */
+ public void l2PutError() {
+ inc("l2.put.error", null);
+ }
+
+ /**
+ * Register the L1 (Caffeine) capacity gauges. Idempotent and global (no dpi). {@code size}
+ * tracks current entry count against the configured {@code cache-max-size}; {@code evictions}
+ * is Caffeine's cumulative eviction count (rate it in the dashboard). No-op when recording is
+ * disabled. ({@code load.failure} is intentionally not exposed — this cache uses manual
+ * {@code put} with no {@code CacheLoader}, so load failures cannot occur.)
+ */
+ public void registerL1Gauges(LongSupplier size, LongSupplier evictions) {
+ gauge("l1.size", size);
+ gauge("l1.eviction", evictions);
+ }
+
+ /**
+ * Register the L2 (Redis) capacity gauges, fed by a periodic poller (Redis stats can't be read
+ * synchronously). Global (no dpi). {@code size} is {@code DBSIZE}, {@code evictions} is the
+ * cumulative {@code evicted_keys} from {@code INFO stats} — both Redis-instance-wide, not
+ * module-scoped. No-op when recording is disabled.
+ */
+ public void registerL2Gauges(LongSupplier size, LongSupplier evictions) {
+ gauge("l2.size", size);
+ gauge("l2.eviction", evictions);
+ }
+
+ protected void inc(String name, String dpi) {
+ metricRegistry.counter(withDpi(name, dpi)).inc();
+ }
+
+ protected void time(String name, String dpi, long timeNanos) {
+ metricRegistry.timer(withDpi(name, dpi)).update(timeNanos, TimeUnit.NANOSECONDS);
+ }
+
+ protected void gauge(String name, LongSupplier supplier) {
+ metricRegistry.gauge(withDpi(name, null), () -> (Gauge) supplier::getAsLong);
+ }
+
+ private static String withDpi(String name, String dpi) {
+ final String full = PREFIX + name;
+ return StringUtils.isNotBlank(dpi) ? full + "_" + dpi : full;
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/NoopIntentiqIdentityMetrics.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/NoopIntentiqIdentityMetrics.java
new file mode 100644
index 00000000000..f6e3a2e931a
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/NoopIntentiqIdentityMetrics.java
@@ -0,0 +1,26 @@
+package org.prebid.server.hooks.modules.intentiq.identity.metric;
+
+import java.util.function.LongSupplier;
+
+/**
+ * Wired in place of {@link IntentiqIdentityMetrics} when {@code metrics-enabled} is false (see
+ * {@code IntentiqIdentityConfig}). Every recording path is a no-op, so callers need no flag check
+ * and the shared {@code MetricRegistry} is never touched.
+ */
+public class NoopIntentiqIdentityMetrics extends IntentiqIdentityMetrics {
+
+ @Override
+ protected void inc(String name, String dpi) {
+ // no-op
+ }
+
+ @Override
+ protected void time(String name, String dpi, long timeNanos) {
+ // no-op
+ }
+
+ @Override
+ protected void gauge(String name, LongSupplier supplier) {
+ // no-op
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/IntentiqIdentityModuleContext.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/IntentiqIdentityModuleContext.java
new file mode 100644
index 00000000000..2a50a46b277
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/IntentiqIdentityModuleContext.java
@@ -0,0 +1,13 @@
+package org.prebid.server.hooks.modules.intentiq.identity.model;
+
+/**
+ * State carried from the processed-auction-request (enrich) hook to the auction-response
+ * (impression report) hook via {@code InvocationResult#moduleContext()} within one auction.
+ *
+ * @param startNanos {@link System#nanoTime()} captured at enrich-hook entry; lets the
+ * auction-response hook record the whole-flow latency (enrich -> bid release)
+ * @param abTestUuid IIQ A/B test id returned by the resolution response, echoed back on the report
+ * @param terminationCause IIQ termination cause ({@code tc}) from the resolution response, if any
+ */
+public record IntentiqIdentityModuleContext(long startNanos, String abTestUuid, Long terminationCause) {
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/CacheProperties.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/CacheProperties.java
new file mode 100644
index 00000000000..a2241ffc241
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/CacheProperties.java
@@ -0,0 +1,43 @@
+package org.prebid.server.hooks.modules.intentiq.identity.model.config;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+public class CacheProperties {
+
+ private boolean enabled;
+
+ /** Positive TTL used when the IntentIQ API omits {@code cttl}. */
+ private int ttlseconds = 43_200;
+
+ /** Max number of alias keys derived per request (guards against eid-stuffed requests). */
+ @JsonProperty("max-keys")
+ private int maxKeys = 10;
+
+ /** Upper bound on positive TTL for first-party id keys (pubcid, MAID, other eids). */
+ @JsonProperty("ttl-ceiling-first-party-seconds")
+ private int ttlCeilingFirstPartySeconds = 86_400;
+
+ /** Upper bound on positive TTL for third-party id keys (intentiq.com). */
+ @JsonProperty("ttl-ceiling-third-party-seconds")
+ private int ttlCeilingThirdPartySeconds = 43_200;
+
+ /** Upper bound on positive TTL for the probabilistic device-composite key. */
+ @JsonProperty("ttl-ceiling-device-seconds")
+ private int ttlCeilingDeviceSeconds = 3_600;
+
+ /** Short TTL for the negative (unresolvable id) sentinel. */
+ @JsonProperty("negative-ttl-seconds")
+ private int negativeTtlSeconds = 120;
+
+ /**
+ * TTL for the IN_PROGRESS marker written while a resolution call is in flight, after which the
+ * marker expires even if the call never completed. Defaults to 30 minutes to match the IntentIQ
+ * backend's in-progress window; lower it if you want a failed resolution to be retried sooner.
+ */
+ @JsonProperty("in-progress-ttl-seconds")
+ private int inProgressTtlSeconds = 1800;
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/IntentiqIdentityProperties.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/IntentiqIdentityProperties.java
new file mode 100644
index 00000000000..1fad945c41f
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/IntentiqIdentityProperties.java
@@ -0,0 +1,32 @@
+package org.prebid.server.hooks.modules.intentiq.identity.model.config;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+public final class IntentiqIdentityProperties {
+
+ @JsonProperty("api-endpoint")
+ String apiEndpoint;
+
+ @JsonProperty("reports-endpoint")
+ String reportsEndpoint;
+
+ @JsonProperty("partner-id")
+ String partnerId;
+
+ Long timeout;
+
+ CacheProperties cache = new CacheProperties();
+
+ RedisProperties redis;
+
+ @JsonProperty("cache-max-size")
+ long cacheMaxSize = 100_000L;
+
+ // Module metrics are recorded by default; can opt out by setting this to false.
+ @JsonProperty("metrics-enabled")
+ boolean metricsEnabled = true;
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/RedisProperties.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/RedisProperties.java
new file mode 100644
index 00000000000..6d2f678a01a
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/RedisProperties.java
@@ -0,0 +1,13 @@
+package org.prebid.server.hooks.modules.intentiq.identity.model.config;
+
+import lombok.Data;
+
+@Data
+public final class RedisProperties {
+
+ String host;
+
+ Integer port;
+
+ String password;
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHook.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHook.java
new file mode 100644
index 00000000000..bdbc9154d1d
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHook.java
@@ -0,0 +1,250 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.iab.openrtb.request.App;
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.Site;
+import com.iab.openrtb.response.Bid;
+import com.iab.openrtb.response.BidResponse;
+import com.iab.openrtb.response.SeatBid;
+import io.vertx.core.Future;
+import org.apache.commons.lang3.StringUtils;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.hooks.execution.v1.InvocationResultImpl;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.hooks.modules.intentiq.identity.model.IntentiqIdentityModuleContext;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.IiqParam;
+import org.prebid.server.hooks.v1.InvocationAction;
+import org.prebid.server.hooks.v1.InvocationResult;
+import org.prebid.server.hooks.v1.InvocationStatus;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.auction.AuctionResponseHook;
+import org.prebid.server.hooks.v1.auction.AuctionResponsePayload;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.log.Logger;
+import org.prebid.server.log.LoggerFactory;
+import org.prebid.server.util.HttpUtil;
+import org.prebid.server.vertx.httpclient.HttpClient;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Runs at the {@code auction-response} stage and reports each winning bid to the IntentIQ S2S
+ * impression-reporting API (fire-and-forget GET to {@code reports-endpoint}). The {@code abTestUuid}
+ * produced by the resolution hook is read from the module context. The bid response is never modified.
+ */
+public class IntentiqIdentityAuctionResponseHook implements AuctionResponseHook {
+
+ private static final Logger logger = LoggerFactory.getLogger(IntentiqIdentityAuctionResponseHook.class);
+
+ public static final String CODE = "intentiq-identity-auction-response-hook";
+
+ private static final long DEFAULT_TIMEOUT_MS = 1000L;
+ private static final String BIDDING_PLATFORM_OPENRTB = "4";
+ private static final String DEFAULT_CURRENCY = "USD";
+
+ // Identifies the request source to the IntentIQ S2S API as prebid-server-java.
+ private static final String SOURCE_PBJV = "pbjv";
+
+ private final ConfigResolver configResolver;
+ private final HttpClient httpClient;
+ private final JacksonMapper mapper;
+ private final IntentiqIdentityMetrics metrics;
+
+ public IntentiqIdentityAuctionResponseHook(ConfigResolver configResolver,
+ HttpClient httpClient,
+ JacksonMapper mapper,
+ IntentiqIdentityMetrics metrics) {
+ this.configResolver = Objects.requireNonNull(configResolver);
+ this.httpClient = Objects.requireNonNull(httpClient);
+ this.mapper = Objects.requireNonNull(mapper);
+ this.metrics = Objects.requireNonNull(metrics);
+ }
+
+ @Override
+ public Future> call(AuctionResponsePayload auctionResponsePayload,
+ AuctionInvocationContext invocationContext) {
+ final IntentiqIdentityProperties properties = configResolver.resolve(invocationContext.accountConfig());
+ final IntentiqIdentityModuleContext context =
+ invocationContext.moduleContext() instanceof IntentiqIdentityModuleContext ctx ? ctx : null;
+ // Whole-flow latency: enrich hook entry (startNanos) -> here (bid release), recorded once
+ // per auction regardless of whether an impression report is configured/sent.
+ if (context != null) {
+ metrics.flowLatency(System.nanoTime() - context.startNanos(), properties.getPartnerId());
+ }
+
+ final BidResponse bidResponse = auctionResponsePayload.bidResponse();
+ if (StringUtils.isBlank(properties.getReportsEndpoint()) || bidResponse == null) {
+ return Future.succeededFuture(noAction());
+ }
+
+ final BidRequest bidRequest = bidRequest(invocationContext);
+ final String abTestUuid = context != null ? context.abTestUuid() : null;
+ final Long terminationCause = context != null ? context.terminationCause() : null;
+ final String currency = bidResponse.getCur() != null ? bidResponse.getCur() : DEFAULT_CURRENCY;
+
+ for (final SeatBid seatBid : nullSafe(bidResponse.getSeatbid())) {
+ for (final Bid bid : nullSafe(seatBid.getBid())) {
+ report(properties, bidRequest, seatBid.getSeat(), bid, currency, abTestUuid, terminationCause);
+ }
+ }
+
+ return Future.succeededFuture(noAction());
+ }
+
+ private void report(IntentiqIdentityProperties properties,
+ BidRequest bidRequest,
+ String bidderCode,
+ Bid bid,
+ String currency,
+ String abTestUuid,
+ Long terminationCause) {
+ final Map rdata = new LinkedHashMap<>();
+ put(rdata, RdataField.BIDDER_CODE, bidderCode);
+ put(rdata, RdataField.PARTNER_ID, properties.getPartnerId());
+ put(rdata, RdataField.CPM, bid.getPrice());
+ put(rdata, RdataField.CURRENCY, currency);
+ appendOriginalBid(rdata, bid);
+ put(rdata, RdataField.PLACEMENT_ID, bid.getImpid());
+ put(rdata, RdataField.BIDDING_PLATFORM_ID, BIDDING_PLATFORM_OPENRTB);
+ putIfPresent(rdata, RdataField.VRREF, resolveRef(bidRequest));
+ final String auctionId = bidRequest != null ? bidRequest.getId() : null;
+ putIfPresent(rdata, RdataField.PREBID_AUCTION_ID, auctionId);
+ putIfPresent(rdata, RdataField.PARTNER_AUCTION_ID, auctionId);
+ putIfPresent(rdata, RdataField.AB_TEST_UUID, abTestUuid);
+ if (terminationCause != null) {
+ put(rdata, RdataField.TERMINATION_CAUSE, terminationCause);
+ }
+ final Device device = bidRequest != null ? bidRequest.getDevice() : null;
+ if (device != null) {
+ putIfPresent(rdata, RdataField.IP,
+ StringUtils.isNotBlank(device.getIp()) ? device.getIp() : device.getIpv6());
+ putIfPresent(rdata, RdataField.UA, device.getUa());
+ }
+
+ final String dpi = properties.getPartnerId();
+
+ final long timeout = properties.getTimeout() != null ? properties.getTimeout() : DEFAULT_TIMEOUT_MS;
+ httpClient.get(reportUrl(properties, rdata), HttpUtil.headers(), timeout)
+ .onSuccess(response -> metrics.impressionReported(dpi))
+ .onFailure(throwable -> {
+ metrics.impressionError(dpi);
+ logger.warn("IntentIQ impression report failed", throwable);
+ });
+ }
+
+ private String reportUrl(IntentiqIdentityProperties properties, Map rdata) {
+ final String endpoint = properties.getReportsEndpoint();
+ return endpoint
+ + (endpoint.contains("?") ? "&" : "?")
+ + IiqParam.AT.key() + "=45"
+ + "&" + IiqParam.RTYPE.key() + "=1"
+ + "&" + IiqParam.SOURCE.key() + "=" + SOURCE_PBJV
+ + "&" + IiqParam.DPI.key() + "=" + encodeComponent(StringUtils.defaultString(properties.getPartnerId()))
+ + "&" + IiqParam.RDATA.key() + "=" + encodeComponent(mapper.encodeToString(rdata));
+ }
+
+ private static String resolveRef(BidRequest bidRequest) {
+ if (bidRequest == null) {
+ return null;
+ }
+ final Site site = bidRequest.getSite();
+ if (site != null) {
+ return StringUtils.isNotBlank(site.getDomain()) ? site.getDomain() : site.getPage();
+ }
+ final App app = bidRequest.getApp();
+ if (app != null) {
+ return StringUtils.isNotBlank(app.getBundle()) ? app.getBundle() : app.getName();
+ }
+ return null;
+ }
+
+ private static BidRequest bidRequest(AuctionInvocationContext invocationContext) {
+ final AuctionContext auctionContext = invocationContext.auctionContext();
+ return auctionContext != null ? auctionContext.getBidRequest() : null;
+ }
+
+ private static void appendOriginalBid(Map rdata, Bid bid) {
+ final ObjectNode ext = bid.getExt();
+ if (ext == null) {
+ return;
+ }
+ final JsonNode originalCpm = ext.get("origbidcpm");
+ if (originalCpm != null && originalCpm.isNumber()) {
+ put(rdata, RdataField.ORIGINAL_CPM, originalCpm.decimalValue());
+ }
+ final JsonNode originalCurrency = ext.get("origbidcur");
+ if (originalCurrency != null && StringUtils.isNotBlank(originalCurrency.asText())) {
+ put(rdata, RdataField.ORIGINAL_CURRENCY, originalCurrency.asText());
+ }
+ }
+
+ private static void put(Map map, RdataField field, Object value) {
+ map.put(field.key(), value);
+ }
+
+ private static void putIfPresent(Map map, RdataField field, String value) {
+ if (StringUtils.isNotBlank(value)) {
+ map.put(field.key(), value);
+ }
+ }
+
+ private static List nullSafe(List list) {
+ return list != null ? list : List.of();
+ }
+
+ private static String encodeComponent(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
+ }
+
+ private static InvocationResult noAction() {
+ return InvocationResultImpl.builder()
+ .status(InvocationStatus.success)
+ .action(InvocationAction.no_action)
+ .build();
+ }
+
+ @Override
+ public String code() {
+ return CODE;
+ }
+
+ /** Field names of the impression-report {@code rdata} JSON object sent to the IntentIQ S2S API. */
+ private enum RdataField {
+
+ BIDDER_CODE("bidderCode"),
+ PARTNER_ID("partnerId"),
+ CPM("cpm"),
+ CURRENCY("currency"),
+ ORIGINAL_CPM("originalCpm"),
+ ORIGINAL_CURRENCY("originalCurrency"),
+ PLACEMENT_ID("placementId"),
+ BIDDING_PLATFORM_ID("biddingPlatformId"),
+ VRREF("vrref"),
+ PREBID_AUCTION_ID("prebidAuctionId"),
+ PARTNER_AUCTION_ID("partnerAuctionId"),
+ AB_TEST_UUID("abTestUuid"),
+ TERMINATION_CAUSE("terminationCause"),
+ IP("ip"),
+ UA("ua");
+
+ private final String key;
+
+ RdataField(String key) {
+ this.key = key;
+ }
+
+ String key() {
+ return key;
+ }
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModule.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModule.java
new file mode 100644
index 00000000000..ca2ba2ec5b4
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModule.java
@@ -0,0 +1,17 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1;
+
+import org.prebid.server.hooks.v1.Hook;
+import org.prebid.server.hooks.v1.InvocationContext;
+import org.prebid.server.hooks.v1.Module;
+
+import java.util.Collection;
+
+public record IntentiqIdentityModule(
+ Collection extends Hook, ? extends InvocationContext>> hooks) implements Module {
+ public static final String CODE = "intentiq-identity";
+
+ @Override
+ public String code() {
+ return CODE;
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHook.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHook.java
new file mode 100644
index 00000000000..a4cb1e6edd0
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHook.java
@@ -0,0 +1,478 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.iab.openrtb.request.App;
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.BrandVersion;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Regs;
+import com.iab.openrtb.request.Site;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import com.iab.openrtb.request.UserAgent;
+import io.vertx.core.Future;
+import io.vertx.core.MultiMap;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+import org.prebid.server.hooks.execution.v1.InvocationResultImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheKey;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheResult;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityCache;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.KeyType;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.hooks.modules.intentiq.identity.model.IntentiqIdentityModuleContext;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.FirstPartyKeyExtractor;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.IiqParam;
+import org.prebid.server.hooks.v1.InvocationAction;
+import org.prebid.server.hooks.v1.InvocationResult;
+import org.prebid.server.hooks.v1.InvocationStatus;
+import org.prebid.server.hooks.v1.PayloadUpdate;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+import org.prebid.server.hooks.v1.auction.ProcessedAuctionRequestHook;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.log.Logger;
+import org.prebid.server.log.LoggerFactory;
+import org.prebid.server.util.HttpUtil;
+import org.prebid.server.vertx.httpclient.HttpClient;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+public class IntentiqIdentityProcessedAuctionRequestHook implements ProcessedAuctionRequestHook {
+
+ private static final Logger logger =
+ LoggerFactory.getLogger(IntentiqIdentityProcessedAuctionRequestHook.class);
+
+ public static final String CODE = "intentiq-identity-processed-auction-request-hook";
+
+ private static final String IIQ_SOURCE = "intentiq.com";
+
+ // Identifies the request source to the IntentIQ S2S API as prebid-server-java.
+ private static final String SOURCE_PBJV = "pbjv";
+
+ // Per the GDPR S2S guide, the TCF consent string is passed via the `gdpr-consent` request header,
+ // not as a query parameter.
+ private static final String GDPR_CONSENT_HEADER = "gdpr-consent";
+
+ private static final long DEFAULT_TIMEOUT_MS = 1000L;
+
+ private final ConfigResolver configResolver;
+ private final HttpClient httpClient;
+ private final JacksonMapper mapper;
+ private final IdentityCache cache;
+ private final FirstPartyKeyExtractor keyExtractor;
+ private final IntentiqIdentityMetrics metrics;
+
+ public IntentiqIdentityProcessedAuctionRequestHook(ConfigResolver configResolver,
+ HttpClient httpClient,
+ JacksonMapper mapper,
+ IdentityCache cache,
+ FirstPartyKeyExtractor keyExtractor,
+ IntentiqIdentityMetrics metrics) {
+ this.configResolver = Objects.requireNonNull(configResolver);
+ this.httpClient = Objects.requireNonNull(httpClient);
+ this.mapper = Objects.requireNonNull(mapper);
+ this.cache = cache;
+ this.keyExtractor = Objects.requireNonNull(keyExtractor);
+ this.metrics = Objects.requireNonNull(metrics);
+ }
+
+ @Override
+ public Future> call(AuctionRequestPayload auctionRequestPayload,
+ AuctionInvocationContext invocationContext) {
+ final long startNanos = System.nanoTime();
+ final IntentiqIdentityProperties properties = configResolver.resolve(invocationContext.accountConfig());
+ final String dpi = properties.getPartnerId();
+ if (StringUtils.isBlank(properties.getApiEndpoint())) {
+ metrics.skipNoEndpoint(dpi);
+ return Future.succeededFuture(noAction(new IntentiqIdentityModuleContext(startNanos, null, null)));
+ }
+
+ return resolveEids(properties, auctionRequestPayload.bidRequest())
+ .map(resolution -> {
+ final List eids = resolution.eids();
+ final IntentiqIdentityModuleContext context = new IntentiqIdentityModuleContext(
+ startNanos, resolution.abTestUuid(), resolution.terminationCause());
+ if (eids == null || eids.isEmpty()) {
+ metrics.eidsNone(dpi);
+ return noAction(context);
+ }
+ metrics.enriched(dpi);
+ return update(payload -> enrichUserEids(payload, eids), context);
+ })
+ .otherwise(throwable -> {
+ logger.warn("IntentIQ identity resolution failed, proceeding without enrichment", throwable);
+ metrics.apiError(dpi);
+ return noAction(new IntentiqIdentityModuleContext(startNanos, null, null));
+ });
+ }
+
+ private Future resolveEids(IntentiqIdentityProperties properties, BidRequest bidRequest) {
+ final boolean cacheEnabled = cache != null && properties.getCache().isEnabled();
+ final List keys = cacheEnabled ? keyExtractor.candidateKeys(bidRequest) : List.of();
+ if (keys.isEmpty()) {
+ return fetch(properties, bidRequest)
+ .map(response -> new Resolution(
+ extractEids(response), response.getAbTestUuid(), response.getTc()));
+ }
+
+ final String dpi = properties.getPartnerId();
+ // Cache counters are broken down by key type: the type of the key that actually matched for
+ // HIT/NEGATIVE/IN_PROGRESS, and the request's primary (highest-priority) candidate type for a
+ // full MISS, where no key matched.
+ final String primaryType = keyTypeToken(keys.getFirst().type());
+ return cache.get(keys).compose(result -> switch (result.state()) {
+ case HIT -> {
+ metrics.cacheHit(layerToken(result.layer()), keyTypeToken(result.keyType()), dpi);
+ yield Future.succeededFuture(new Resolution(result.eids(), null, null));
+ }
+ case NEGATIVE -> {
+ // A negative sentinel is a cached miss (id is known-unresolvable): no identity to
+ // serve, so it counts toward cache.miss, not cache.hit. The negative-specific counter
+ // distinguishes it from a true miss (no API call is made here) and is tagged by layer.
+ final String type = keyTypeToken(result.keyType());
+ metrics.cacheMiss(type, dpi);
+ metrics.cacheNegativeHit(layerToken(result.layer()), type, dpi);
+ yield Future.succeededFuture(new Resolution(null, null, null));
+ }
+ case IN_PROGRESS -> {
+ // A resolution call for this id is already in flight; skip firing a duplicate and
+ // proceed without enrichment (the in-flight call will populate the cache).
+ metrics.cacheInProgress(layerToken(result.layer()), keyTypeToken(result.keyType()), dpi);
+ yield Future.succeededFuture(new Resolution(null, null, null));
+ }
+ case MISS -> {
+ metrics.cacheMiss(primaryType, dpi);
+ cache.putInProgress(keys);
+ yield fetchAndCache(properties, bidRequest, keys);
+ }
+ });
+ }
+
+ // Short, stable, lowercase metric token for a cache key type (e.g. THIRD_PARTY -> "third_party").
+ private static String keyTypeToken(KeyType type) {
+ return type != null ? type.name().toLowerCase(Locale.ROOT) : "unknown";
+ }
+
+ // Cache layer that served the outcome: "l1" (Caffeine) or "l2" (Redis).
+ private static String layerToken(CacheResult.Layer layer) {
+ return layer != null ? layer.name().toLowerCase(Locale.ROOT) : "unknown";
+ }
+
+ private Future fetchAndCache(IntentiqIdentityProperties properties,
+ BidRequest bidRequest,
+ List keys) {
+ return fetch(properties, bidRequest).map(response -> {
+ final List eids = extractEids(response);
+ if (eids != null && !eids.isEmpty()) {
+ cache.put(keys, eids, response.getCttl() != null ? response.getCttl() : 0L);
+ } else {
+ cache.putNegative(keys, response.getCttl() != null ? response.getCttl() : 0L);
+ }
+ return new Resolution(eids, response.getAbTestUuid(), response.getTc());
+ });
+ }
+
+ private Future fetch(IntentiqIdentityProperties properties, BidRequest bidRequest) {
+ final long timeout = properties.getTimeout() != null ? properties.getTimeout() : DEFAULT_TIMEOUT_MS;
+ final long startNanos = System.nanoTime();
+ return httpClient.get(resolveUrl(properties, bidRequest), resolveHeaders(bidRequest), timeout)
+ .map(response -> {
+ final IiqResponse parsed = mapper.decodeValue(response.getBody(), IiqResponse.class);
+ metrics.apiSuccess(properties.getPartnerId());
+ if (parsed != null && parsed.getTc() != null) {
+ metrics.terminationCause(parsed.getTc(), properties.getPartnerId());
+ }
+ return parsed;
+ })
+ .onComplete(ignored -> metrics.apiLatency(System.nanoTime() - startNanos, properties.getPartnerId()));
+ }
+
+ private static List extractEids(IiqResponse response) {
+ return response != null && response.getData() != null ? response.getData().getEids() : null;
+ }
+
+ private String resolveUrl(IntentiqIdentityProperties properties, BidRequest bidRequest) {
+ final String apiEndpoint = properties.getApiEndpoint();
+ final StringBuilder url = new StringBuilder(apiEndpoint);
+ url.append(apiEndpoint.contains("?") ? '&' : '?').append(IiqParam.AT.key()).append("=39");
+ appendIfPresent(url, IiqParam.MI, "10");
+ appendIfPresent(url, IiqParam.DPI, properties.getPartnerId());
+ appendIfPresent(url, IiqParam.PT, "17");
+ appendIfPresent(url, IiqParam.DPN, "1");
+ appendIfPresent(url, IiqParam.SRVR_REQ, "true");
+ appendIfPresent(url, IiqParam.SOURCE, SOURCE_PBJV);
+
+ final Device device = bidRequest.getDevice();
+ if (device != null) {
+ appendIfPresent(url, IiqParam.IP, device.getIp());
+ appendIfPresent(url, IiqParam.IPV6, device.getIpv6());
+ appendIfPresent(url, IiqParam.UAS, device.getUa());
+ appendIfPresent(url, IiqParam.UH, buildUaHints(device.getSua()));
+ appendDeviceId(url, device);
+ }
+ appendIfPresent(url, IiqParam.REF, resolveRef(bidRequest));
+ appendIfPresent(url, IiqParam.IIQUID, resolveIiqUid(bidRequest.getUser()));
+ appendConsent(url, bidRequest);
+
+ return url.toString();
+ }
+
+ private static void appendConsent(StringBuilder url, BidRequest bidRequest) {
+ final Regs regs = bidRequest.getRegs();
+ if (regs != null) {
+ appendIfPresent(url, IiqParam.GDPR, resolveGdpr(regs));
+ appendIfPresent(url, IiqParam.US_PRIVACY, resolveUsPrivacy(regs));
+ appendIfPresent(url, IiqParam.GPP, regs.getGpp());
+ // Forwarded ahead of backend support so the GPP section ids are available if the backend adds gpp_sid.
+ appendIfPresent(url, IiqParam.GPP_SID, StringUtils.join(regs.getGppSid(), ','));
+ }
+ }
+
+ private static MultiMap resolveHeaders(BidRequest bidRequest) {
+ final MultiMap headers = HttpUtil.headers();
+ HttpUtil.addHeaderIfValueIsNotEmpty(headers, GDPR_CONSENT_HEADER, resolveConsent(bidRequest.getUser()));
+ return headers;
+ }
+
+ private static String resolveGdpr(Regs regs) {
+ final Integer gdpr = regs.getGdpr() != null ? regs.getGdpr()
+ : regs.getExt() != null ? regs.getExt().getGdpr() : null;
+ return gdpr != null ? String.valueOf(gdpr) : null;
+ }
+
+ private static String resolveUsPrivacy(Regs regs) {
+ if (StringUtils.isNotBlank(regs.getUsPrivacy())) {
+ return regs.getUsPrivacy();
+ }
+ return regs.getExt() != null ? regs.getExt().getUsPrivacy() : null;
+ }
+
+ private static String resolveConsent(User user) {
+ if (user == null) {
+ return null;
+ }
+ if (StringUtils.isNotBlank(user.getConsent())) {
+ return user.getConsent();
+ }
+ return user.getExt() != null ? user.getExt().getConsent() : null;
+ }
+
+ private static void appendIfPresent(StringBuilder url, IiqParam param, String value) {
+ if (StringUtils.isNotBlank(value)) {
+ url.append('&').append(param.key()).append('=').append(encodeComponent(value));
+ }
+ }
+
+ // Builds the `uh` UA client-hints JSON from OpenRTB device.sua, matching the IntentIQ backend's
+ // UA-CH hint format: numeric-keyed (0-8) UA-CH values, brands sorted, major vs full version.
+ private String buildUaHints(UserAgent sua) {
+ // The IntentIQ backend consumes hints only for high-entropy client hints (sua.source == 2).
+ if (sua == null || !Integer.valueOf(2).equals(sua.getSource())) {
+ return null;
+ }
+ final Map hints = new LinkedHashMap<>();
+ appendBrowserHints(hints, sua.getBrowsers());
+ if (sua.getMobile() != null) {
+ hints.put("1", "?" + sua.getMobile());
+ }
+ appendPlatformHints(hints, sua.getPlatform());
+ putQuoted(hints, "3", sua.getArchitecture());
+ putQuoted(hints, "4", sua.getBitness());
+ putQuoted(hints, "5", sua.getModel());
+ return hints.isEmpty() ? null : mapper.encodeToString(hints);
+ }
+
+ private static void appendBrowserHints(Map hints, List browsers) {
+ if (browsers == null) {
+ return;
+ }
+ // Sorted by brand to match the IntentIQ backend's hint formatting (deterministic hint string).
+ final TreeMap majorByBrand = new TreeMap<>();
+ final TreeMap fullByBrand = new TreeMap<>();
+ for (BrandVersion browser : browsers) {
+ if (browser == null || StringUtils.isBlank(browser.getBrand())
+ || browser.getVersion() == null || browser.getVersion().isEmpty()) {
+ continue;
+ }
+ final String fullVersion = String.join(".", browser.getVersion());
+ final int dot = fullVersion.indexOf('.');
+ majorByBrand.put(browser.getBrand(), dot > 0 ? fullVersion.substring(0, dot) : fullVersion);
+ fullByBrand.put(browser.getBrand(), fullVersion);
+ }
+ putBrandList(hints, "0", majorByBrand);
+ putBrandList(hints, "8", fullByBrand);
+ }
+
+ private static void putBrandList(Map hints, String key, TreeMap brandToVersion) {
+ if (brandToVersion.isEmpty()) {
+ return;
+ }
+ hints.put(key, brandToVersion.entrySet().stream()
+ .map(entry -> quote(entry.getKey()) + ";v=" + quote(entry.getValue()))
+ .collect(Collectors.joining(", ")));
+ }
+
+ private static void appendPlatformHints(Map hints, BrandVersion platform) {
+ if (platform == null || StringUtils.isBlank(platform.getBrand())) {
+ return;
+ }
+ hints.put("2", quote(platform.getBrand()));
+ if (platform.getVersion() != null && !platform.getVersion().isEmpty()) {
+ hints.put("6", quote(String.join(".", platform.getVersion())));
+ }
+ }
+
+ private static void putQuoted(Map hints, String key, String value) {
+ if (StringUtils.isNotBlank(value)) {
+ hints.put(key, quote(value));
+ }
+ }
+
+ private static String quote(String value) {
+ return "\"" + value + "\"";
+ }
+
+ private static void appendDeviceId(StringBuilder url, Device device) {
+ final String ifa = device.getIfa();
+ if (StringUtils.isBlank(ifa) || Integer.valueOf(1).equals(device.getLmt())) {
+ return;
+ }
+
+ final Integer deviceType = device.getDevicetype();
+ final boolean ctv = deviceType != null && (deviceType == 3 || deviceType == 7);
+ // CTV ids (idtype 8) must be uppercase; MAID/AAID (idtype 4) is case-insensitive.
+ final String pcid = ctv ? ifa.toUpperCase(Locale.ROOT) : ifa;
+ url.append('&').append(IiqParam.PCID.key()).append('=').append(encodeComponent(pcid))
+ .append('&').append(IiqParam.IDTYPE.key()).append('=').append(ctv ? 8 : 4);
+ }
+
+ private static String resolveRef(BidRequest bidRequest) {
+ final Site site = bidRequest.getSite();
+ if (site != null) {
+ return StringUtils.isNotBlank(site.getDomain()) ? site.getDomain() : site.getPage();
+ }
+ final App app = bidRequest.getApp();
+ if (app != null) {
+ return StringUtils.isNotBlank(app.getBundle()) ? app.getBundle() : app.getName();
+ }
+ return null;
+ }
+
+ private static String resolveIiqUid(User user) {
+ if (user == null || user.getEids() == null) {
+ return null;
+ }
+ return user.getEids().stream()
+ .filter(eid -> eid != null && IIQ_SOURCE.equals(eid.getSource()))
+ .map(Eid::getUids)
+ .filter(Objects::nonNull)
+ .flatMap(List::stream)
+ .filter(Objects::nonNull)
+ .map(Uid::getId)
+ .filter(StringUtils::isNotBlank)
+ .findFirst()
+ .orElse(null);
+ }
+
+ private static String encodeComponent(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
+ }
+
+ private static AuctionRequestPayload enrichUserEids(AuctionRequestPayload payload, List resolvedEids) {
+ final BidRequest bidRequest = payload.bidRequest();
+ final User existingUser = bidRequest.getUser() != null ? bidRequest.getUser() : User.builder().build();
+
+ final List mergedEids = new ArrayList<>();
+ if (existingUser.getEids() != null) {
+ mergedEids.addAll(existingUser.getEids());
+ }
+ mergedEids.addAll(resolvedEids);
+
+ final User enrichedUser = existingUser.toBuilder().eids(mergedEids).build();
+ return AuctionRequestPayloadImpl.of(bidRequest.toBuilder().user(enrichedUser).build());
+ }
+
+ private static InvocationResult update(PayloadUpdate payloadUpdate,
+ IntentiqIdentityModuleContext context) {
+ return InvocationResultImpl.builder()
+ .status(InvocationStatus.success)
+ .action(InvocationAction.update)
+ .payloadUpdate(payloadUpdate)
+ .moduleContext(context)
+ .build();
+ }
+
+ private static InvocationResult noAction(IntentiqIdentityModuleContext context) {
+ return InvocationResultImpl.builder()
+ .status(InvocationStatus.success)
+ .action(InvocationAction.no_action)
+ .moduleContext(context)
+ .build();
+ }
+
+ @Override
+ public String code() {
+ return CODE;
+ }
+
+ private record Resolution(List eids, String abTestUuid, Long terminationCause) {
+ }
+
+ @Data
+ @NoArgsConstructor
+ static class IiqResponse {
+
+ @JsonDeserialize(using = LenientIiqDataDeserializer.class)
+ IiqData data;
+
+ Long cttl;
+
+ String abTestUuid;
+
+ Long tc;
+ }
+
+ @Data
+ @NoArgsConstructor
+ static class IiqData {
+
+ List eids;
+ }
+
+ /**
+ * IntentIQ returns {@code data} as an object on a hit but as an empty string ({@code ""}) on an
+ * empty or invalid response. Tolerate the non-object form by treating it as absent rather than
+ * failing the whole parse (which would mask a valid empty response as an API error).
+ */
+ static class LenientIiqDataDeserializer extends JsonDeserializer {
+
+ @Override
+ public IiqData deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+ final JsonNode node = parser.readValueAsTree();
+ if (node == null || !node.isObject()) {
+ return null;
+ }
+ return parser.getCodec().treeToValue(node, IiqData.class);
+ }
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/ConfigResolver.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/ConfigResolver.java
new file mode 100644
index 00000000000..c3b4fa95214
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/ConfigResolver.java
@@ -0,0 +1,50 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1.core;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties;
+import org.prebid.server.json.JsonMerger;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Resolves effective module properties for a request by merging the account-level config
+ * (from {@code AuctionInvocationContext.accountConfig()}) over the host-level (global) properties.
+ * Mirrors the optable-targeting module's resolver.
+ */
+public class ConfigResolver {
+
+ private final ObjectMapper mapper;
+ private final JsonMerger jsonMerger;
+ private final IntentiqIdentityProperties globalProperties;
+ private final JsonNode globalPropertiesNode;
+
+ public ConfigResolver(ObjectMapper mapper, JsonMerger jsonMerger, IntentiqIdentityProperties globalProperties) {
+ this.mapper = Objects.requireNonNull(mapper);
+ this.jsonMerger = Objects.requireNonNull(jsonMerger);
+ this.globalProperties = Objects.requireNonNull(globalProperties);
+ this.globalPropertiesNode = Objects.requireNonNull(mapper.valueToTree(globalProperties));
+ }
+
+ public IntentiqIdentityProperties resolve(ObjectNode accountConfig) {
+ // With host-level-only config (no account override) accountConfig is null; JsonMergePatch
+ // rejects a null patch ("input cannot be null"), so short-circuit to the global properties.
+ if (accountConfig == null || accountConfig.isEmpty()) {
+ return globalProperties;
+ }
+ final JsonNode merged = jsonMerger.merge(accountConfig, globalPropertiesNode);
+ return parse(merged).orElse(globalProperties);
+ }
+
+ private Optional parse(JsonNode node) {
+ try {
+ return Optional.ofNullable(node)
+ .filter(it -> !it.isEmpty())
+ .map(it -> mapper.convertValue(it, IntentiqIdentityProperties.class));
+ } catch (IllegalArgumentException e) {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgent.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgent.java
new file mode 100644
index 00000000000..73c0ed7d61a
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgent.java
@@ -0,0 +1,58 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1.core;
+
+import org.apache.commons.lang3.StringUtils;
+import ua_parser.Client;
+import ua_parser.Parser;
+
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Normalizes a raw User-Agent string into a compact, deterministic cache-key segment.
+ *
+ * The IntentIQ caching guidance asks integrators to key on normalized UA fields
+ * (OS family/version, browser family/major, device family) rather than the full raw UA string,
+ * which would fragment the cache. The resulting segment looks like {@code iOS17_MobileSafari17_iPhone}.
+ *
+ *
Parsing is deterministic, so the same device always yields the same segment — which is all the
+ * module's self-contained cache requires. (Rendering engine and app-vs-browser, shown in some guide
+ * samples, are not exposed by this parser and are intentionally omitted.)
+ */
+public final class DeviceUserAgent {
+
+ private static final Parser UA_PARSER = new Parser();
+ private static final String UNKNOWN = "Other";
+
+ private DeviceUserAgent() {
+ }
+
+ /**
+ * Returns the normalized UA segment, or an empty string when {@code ua} is blank or yields no
+ * recognizable fields.
+ */
+ public static String normalize(String ua) {
+ if (StringUtils.isBlank(ua)) {
+ return StringUtils.EMPTY;
+ }
+
+ final Client client = UA_PARSER.parse(ua);
+ final String os = client.os != null ? token(client.os.family, client.os.major) : null;
+ final String browser = client.userAgent != null
+ ? token(client.userAgent.family, client.userAgent.major)
+ : null;
+ final String device = client.device != null ? token(client.device.family, null) : null;
+
+ return Stream.of(os, browser, device)
+ .filter(StringUtils::isNotBlank)
+ .collect(Collectors.joining("_"));
+ }
+
+ private static String token(String family, String version) {
+ if (StringUtils.isBlank(family) || UNKNOWN.equals(family)) {
+ return null;
+ }
+ final String value = family + StringUtils.defaultString(version);
+ return Optional.of(value.replaceAll("\\s+", "")).filter(StringUtils::isNotBlank).orElse(null);
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractor.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractor.java
new file mode 100644
index 00000000000..412ee620069
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractor.java
@@ -0,0 +1,130 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1.core;
+
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import org.apache.commons.lang3.StringUtils;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheKey;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.KeyType;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Derives the ordered list of alias cache keys for a bid request. Each relevant first-party id present
+ * becomes one namespaced key; the resolved identity is aliased across all of them so a later request
+ * carrying any of those ids hits the cache.
+ *
+ *
Priority (highest first): IIQ eid, shared id / pubcid, device MAID, any other eid source, then a
+ * probabilistic device composite as last resort. Keys are lower-cased, de-duplicated (first occurrence
+ * wins) and capped at {@code maxKeys}. {@code device.lmt == 1} suppresses the MAID key; CTV ifas are
+ * upper-cased to match the resolution request's {@code idtype 8} handling.
+ */
+public class FirstPartyKeyExtractor {
+
+ private static final String IIQ_SOURCE = "intentiq.com";
+ private static final Set SHARED_SOURCES = Set.of("pubcid.org", "sharedid.org");
+
+ private final int maxKeys;
+
+ public FirstPartyKeyExtractor(int maxKeys) {
+ this.maxKeys = maxKeys;
+ }
+
+ public List candidateKeys(BidRequest bidRequest) {
+ final List keys = new ArrayList<>();
+ final User user = bidRequest.getUser();
+ final List eids = user != null ? user.getEids() : null;
+ final Device device = bidRequest.getDevice();
+
+ addEidKeys(keys, eids, "iiq", KeyType.THIRD_PARTY, IIQ_SOURCE::equals);
+ addEidKeys(keys, eids, "pubcid", KeyType.FIRST_PARTY, SHARED_SOURCES::contains);
+ addMaidKey(keys, device);
+ addOtherEidKeys(keys, eids);
+ addDeviceComposite(keys, device);
+
+ return dedupAndCap(keys);
+ }
+
+ private static void addEidKeys(List keys,
+ List eids,
+ String namespace,
+ KeyType type,
+ java.util.function.Predicate sourceMatch) {
+ if (eids == null) {
+ return;
+ }
+ eids.stream()
+ .filter(eid -> eid != null && eid.getSource() != null && sourceMatch.test(eid.getSource()))
+ .map(Eid::getUids)
+ .filter(Objects::nonNull)
+ .flatMap(List::stream)
+ .filter(Objects::nonNull)
+ .map(Uid::getId)
+ .filter(StringUtils::isNotBlank)
+ .forEach(id -> keys.add(new CacheKey(namespace + ":" + id, type)));
+ }
+
+ private static void addMaidKey(List keys, Device device) {
+ if (device == null || StringUtils.isBlank(device.getIfa()) || Integer.valueOf(1).equals(device.getLmt())) {
+ return;
+ }
+ final Integer deviceType = device.getDevicetype();
+ final boolean ctv = deviceType != null && (deviceType == 3 || deviceType == 7);
+ final String ifa = ctv ? device.getIfa().toUpperCase(Locale.ROOT) : device.getIfa();
+ keys.add(new CacheKey("maid:" + ifa, KeyType.FIRST_PARTY));
+ }
+
+ private static void addOtherEidKeys(List keys, List eids) {
+ if (eids == null) {
+ return;
+ }
+ for (Eid eid : eids) {
+ if (eid == null || eid.getSource() == null || eid.getUids() == null) {
+ continue;
+ }
+ final String source = eid.getSource();
+ if (IIQ_SOURCE.equals(source) || SHARED_SOURCES.contains(source)) {
+ continue;
+ }
+ final String namespace = source.toLowerCase(Locale.ROOT);
+ for (Uid uid : eid.getUids()) {
+ if (uid != null && StringUtils.isNotBlank(uid.getId())) {
+ keys.add(new CacheKey(namespace + ":" + uid.getId(), KeyType.FIRST_PARTY));
+ }
+ }
+ }
+ }
+
+ private static void addDeviceComposite(List keys, Device device) {
+ if (device == null) {
+ return;
+ }
+ final String ip = StringUtils.isNotBlank(device.getIp()) ? device.getIp() : device.getIpv6();
+ // Normalized UA fields (not the raw UA string) to avoid fragmenting the cache per request.
+ final String ua = DeviceUserAgent.normalize(device.getUa());
+ final String composite = Stream.of(device.getIfa(), StringUtils.trimToNull(ua), ip)
+ .filter(StringUtils::isNotBlank)
+ .collect(Collectors.joining("_"));
+ if (!composite.isEmpty()) {
+ keys.add(new CacheKey("dev:" + composite, KeyType.DEVICE));
+ }
+ }
+
+ private List dedupAndCap(List keys) {
+ final Map unique = new LinkedHashMap<>();
+ for (CacheKey key : keys) {
+ unique.putIfAbsent(key.key(), key);
+ }
+ return unique.values().stream().limit(maxKeys).collect(Collectors.toList());
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/IiqParam.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/IiqParam.java
new file mode 100644
index 00000000000..1315368f985
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/IiqParam.java
@@ -0,0 +1,45 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1.core;
+
+/**
+ * Query-parameter names of the IntentIQ S2S HTTP API, shared by both the identity-resolution
+ * request and the impression-report request. The enum constant carries the on-the-wire key;
+ * the value is request-specific and supplied at the call site (e.g. {@code at} is {@code 39} for the
+ * resolution request and {@code 45} for the impression report).
+ */
+public enum IiqParam {
+
+ // Common / identity-resolution request
+ AT("at"),
+ MI("mi"),
+ DPI("dpi"),
+ PT("pt"),
+ DPN("dpn"),
+ SRVR_REQ("srvrReq"),
+ SOURCE("source"),
+ IP("ip"),
+ IPV6("ipv6"),
+ UAS("uas"),
+ UH("uh"),
+ PCID("pcid"),
+ IDTYPE("idtype"),
+ REF("ref"),
+ IIQUID("iiquid"),
+ GDPR("gdpr"),
+ US_PRIVACY("us_privacy"),
+ GPP("gpp"),
+ GPP_SID("gpp_sid"),
+
+ // Impression-report request
+ RTYPE("rtype"),
+ RDATA("rdata");
+
+ private final String key;
+
+ IiqParam(String key) {
+ this.key = key;
+ }
+
+ public String key() {
+ return key;
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCacheTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCacheTest.java
new file mode 100644
index 00000000000..5774bbaf14c
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCacheTest.java
@@ -0,0 +1,447 @@
+package org.prebid.server.hooks.modules.intentiq.identity.cache;
+
+import com.codahale.metrics.MetricRegistry;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Uid;
+import io.vertx.core.Future;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.json.ObjectMapperProvider;
+
+import java.util.List;
+
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class IdentityCacheTest {
+
+ private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper());
+
+ // defaultTtl 12h, firstParty ceiling 24h, thirdParty ceiling 12h, device ceiling 1h, negative 2min,
+ // in-progress 60s
+ private static final CacheTtlPolicy POLICY =
+ new CacheTtlPolicy(43_200_000L, 86_400_000L, 43_200_000L, 3_600_000L, 120_000L, 60_000L);
+
+ private static final CacheKey IIQ = new CacheKey("iiq:uid-1", KeyType.THIRD_PARTY);
+ private static final CacheKey PUBCID = new CacheKey("pubcid:p1", KeyType.FIRST_PARTY);
+ private static final CacheKey DEV = new CacheKey("dev:ifa_ua_ip", KeyType.DEVICE);
+
+ @Mock
+ private IdentityStore store;
+
+ private IdentityCache target;
+ private MetricRegistry metricRegistry;
+
+ private final List eids = singletonList(Eid.builder()
+ .source("intentiq.com")
+ .uids(singletonList(Uid.builder().id("uid-1").build()))
+ .build());
+
+ @BeforeEach
+ public void setUp() {
+ metricRegistry = new MetricRegistry();
+ target = new IdentityCache(100L, POLICY, store, MAPPER, new IntentiqIdentityMetrics(metricRegistry));
+ }
+
+ @Test
+ public void getShouldReturnFromLocalCacheWithoutQueryingStoreAfterPut() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+ target.put(List.of(IIQ), eids, 1000L);
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ)).result();
+
+ // then
+ assertThat(result.state()).isEqualTo(CacheResult.State.HIT);
+ assertThat(result.eids()).isEqualTo(eids);
+ verify(store, never()).get(any());
+ }
+
+ @Test
+ public void putShouldRefreshExpiryWhenSameKeyWrittenAgain() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+ target.put(List.of(IIQ), eids, 1000L);
+
+ // when — a second write to the same key updates the existing L1 entry (exercises expireAfterUpdate)
+ target.put(List.of(IIQ), eids, 5000L);
+
+ // then
+ final CacheResult result = target.get(List.of(IIQ)).result();
+ assertThat(result.state()).isEqualTo(CacheResult.State.HIT);
+ assertThat(result.eids()).isEqualTo(eids);
+ }
+
+ @Test
+ public void putShouldWriteToStoreWithEffectiveTtlFromCttl() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+
+ // when — cttl below every ceiling is used verbatim
+ target.put(List.of(IIQ), eids, 1000L);
+
+ // then
+ verify(store).put(eq("iiq:uid-1"), any(), eq(1000L));
+ }
+
+ @Test
+ public void putShouldUseDefaultTtlWhenCttlIsNotPositive() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+
+ // when — third-party ceiling (12h) equals default, so default applies
+ target.put(List.of(IIQ), eids, 0L);
+
+ // then
+ verify(store).put(eq("iiq:uid-1"), any(), eq(43_200_000L));
+ }
+
+ @Test
+ public void putShouldCapCttlAtPerTypeCeiling() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+
+ // when — huge cttl on a device key is capped at the 1h device ceiling
+ target.put(List.of(DEV), eids, 999_999_999L);
+
+ // then
+ verify(store).put(eq("dev:ifa_ua_ip"), any(), eq(3_600_000L));
+ }
+
+ @Test
+ public void putShouldWriteEntryUnderEveryKey() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+
+ // when
+ target.put(List.of(IIQ, PUBCID), eids, 1000L);
+
+ // then
+ verify(store).put(eq("iiq:uid-1"), any(), eq(1000L));
+ verify(store).put(eq("pubcid:p1"), any(), eq(1000L));
+ }
+
+ @Test
+ public void getShouldReturnMissOnFullMiss() {
+ // given
+ when(store.get(any())).thenReturn(Future.succeededFuture(null));
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ)).result();
+
+ // then
+ assertThat(result.state()).isEqualTo(CacheResult.State.MISS);
+ verify(store).get("iiq:uid-1");
+ }
+
+ @Test
+ public void getShouldReturnMissForEmptyKeys() {
+ // when
+ final CacheResult result = target.get(List.of()).result();
+
+ // then
+ assertThat(result.state()).isEqualTo(CacheResult.State.MISS);
+ verify(store, never()).get(any());
+ }
+
+ @Test
+ public void getShouldFallOpenToMissWhenStoreFails() {
+ // given
+ when(store.get(any())).thenReturn(Future.failedFuture(new RuntimeException("down")));
+
+ // when
+ final Future future = target.get(List.of(IIQ));
+
+ // then
+ assertThat(future.succeeded()).isTrue();
+ assertThat(future.result().state()).isEqualTo(CacheResult.State.MISS);
+ }
+
+ @Test
+ public void getShouldReturnHitFromStoreWhenPresentAndNotExpired() {
+ // given
+ when(store.get("iiq:uid-1"))
+ .thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() + 60_000L)));
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ)).result();
+
+ // then
+ assertThat(result.state()).isEqualTo(CacheResult.State.HIT);
+ assertThat(result.eids()).isEqualTo(eids);
+ }
+
+ @Test
+ public void getShouldReturnMissWhenStoreEntryIsExpired() {
+ // given
+ when(store.get(any())).thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() - 1L)));
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ)).result();
+
+ // then
+ assertThat(result.state()).isEqualTo(CacheResult.State.MISS);
+ }
+
+ @Test
+ public void getShouldPromoteStoreHitToLocalCacheAndNotQueryStoreAgain() {
+ // given
+ when(store.get("iiq:uid-1"))
+ .thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() + 60_000L)));
+ target.get(List.of(IIQ)).result();
+
+ // when — second lookup
+ final CacheResult result = target.get(List.of(IIQ)).result();
+
+ // then
+ assertThat(result.eids()).isEqualTo(eids);
+ verify(store).get("iiq:uid-1");
+ }
+
+ @Test
+ public void getShouldReturnHighestPriorityHitAndBackfillMissedKeys() {
+ // given — first (highest-priority) key misses, second hits
+ lenient().when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+ when(store.get("iiq:uid-1")).thenReturn(Future.succeededFuture(null));
+ when(store.get("pubcid:p1"))
+ .thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() + 60_000L)));
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ, PUBCID)).result();
+
+ // then — the hit is returned and the missed higher-priority key is back-filled
+ assertThat(result.state()).isEqualTo(CacheResult.State.HIT);
+ assertThat(result.eids()).isEqualTo(eids);
+ verify(store).put(eq("iiq:uid-1"), any(), anyLong());
+ }
+
+ @Test
+ public void getShouldBackfillFromLocalHitUnderOtherKeys() {
+ // given — IIQ already in local cache, PUBCID not present anywhere
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+ target.put(List.of(IIQ), eids, 60_000L);
+
+ // when — lookup with both keys hits IIQ in L1 and back-fills PUBCID
+ final CacheResult result = target.get(List.of(IIQ, PUBCID)).result();
+
+ // then
+ assertThat(result.state()).isEqualTo(CacheResult.State.HIT);
+ verify(store).put(eq("pubcid:p1"), any(), anyLong());
+ }
+
+ @Test
+ public void putNegativeShouldYieldNegativeResultWithoutQueryingStore() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+ target.putNegative(List.of(IIQ), 0L);
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ)).result();
+
+ // then
+ assertThat(result.state()).isEqualTo(CacheResult.State.NEGATIVE);
+ verify(store, never()).get(any());
+ }
+
+ @Test
+ public void putNegativeShouldWriteSentinelUnderAllKeysWithDefaultTtlWhenCttlAbsent() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+
+ // when
+ target.putNegative(List.of(IIQ, PUBCID), 0L);
+
+ // then
+ verify(store).put(eq("iiq:uid-1"), any(), eq(120_000L));
+ verify(store).put(eq("pubcid:p1"), any(), eq(120_000L));
+ }
+
+ @Test
+ public void putNegativeShouldHonorResponseCttlAsSuppressionTtl() {
+ // given — the BE signals the suppression window via cttl on an empty/invalid response
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+
+ // when
+ target.putNegative(List.of(IIQ), 600_000L);
+
+ // then
+ verify(store).put(eq("iiq:uid-1"), any(), eq(600_000L));
+ }
+
+ @Test
+ public void putNegativeShouldCapResponseCttlAtFirstPartyCeiling() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+
+ // when — an absurd cttl is bounded by the first-party ceiling (24h)
+ target.putNegative(List.of(IIQ), 999_999_999_999L);
+
+ // then
+ verify(store).put(eq("iiq:uid-1"), any(), eq(86_400_000L));
+ }
+
+ @Test
+ public void getShouldReturnNegativeFromStoreSentinel() {
+ // given
+ when(store.get("iiq:uid-1"))
+ .thenReturn(Future.succeededFuture(negativeEntry(System.currentTimeMillis() + 60_000L)));
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ)).result();
+
+ // then
+ assertThat(result.state()).isEqualTo(CacheResult.State.NEGATIVE);
+ }
+
+ @Test
+ public void putShouldStoreLocallyEvenWhenStorePutFails() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.failedFuture(new RuntimeException("down")));
+
+ // when
+ target.put(List.of(IIQ), eids, 1000L);
+
+ // then — local layer still serves the value without touching the store
+ assertThat(target.get(List.of(IIQ)).result().eids()).isEqualTo(eids);
+ verify(store, never()).get(any());
+ }
+
+ @Test
+ public void putInProgressShouldWriteSentinelUnderAllKeysWithInProgressTtl() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+
+ // when
+ target.putInProgress(List.of(IIQ, PUBCID));
+
+ // then — marker written under every key with the in-progress TTL (60s)
+ verify(store).put(eq("iiq:uid-1"), any(), eq(60_000L));
+ verify(store).put(eq("pubcid:p1"), any(), eq(60_000L));
+ }
+
+ @Test
+ public void getShouldReturnInProgressAfterPutInProgress() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+ target.putInProgress(List.of(IIQ));
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ)).result();
+
+ // then — served from L1 without touching the store
+ assertThat(result.state()).isEqualTo(CacheResult.State.IN_PROGRESS);
+ assertThat(result.eids()).isEmpty();
+ verify(store, never()).get(any());
+ }
+
+ @Test
+ public void getShouldReturnInProgressFromStoreSentinel() {
+ // given
+ when(store.get("iiq:uid-1"))
+ .thenReturn(Future.succeededFuture(inProgressEntry(System.currentTimeMillis() + 60_000L)));
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ)).result();
+
+ // then
+ assertThat(result.state()).isEqualTo(CacheResult.State.IN_PROGRESS);
+ }
+
+ @Test
+ public void getShouldPreferResolvedHitOverInProgressMarker() {
+ // given — highest-priority key is in progress, a lower-priority key already resolved
+ lenient().when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+ when(store.get("iiq:uid-1"))
+ .thenReturn(Future.succeededFuture(inProgressEntry(System.currentTimeMillis() + 60_000L)));
+ when(store.get("pubcid:p1"))
+ .thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() + 60_000L)));
+
+ // when
+ final CacheResult result = target.get(List.of(IIQ, PUBCID)).result();
+
+ // then — the resolved entry wins over the in-progress marker
+ assertThat(result.state()).isEqualTo(CacheResult.State.HIT);
+ assertThat(result.eids()).isEqualTo(eids);
+ }
+
+ @Test
+ public void getShouldRecordStoreGetErrorAndLatencyWhenL2GetFails() {
+ // given
+ when(store.get(any())).thenReturn(Future.failedFuture(new RuntimeException("down")));
+
+ // when
+ target.get(List.of(IIQ)).result();
+
+ // then — the otherwise-swallowed L2 failure is counted, and latency is recorded regardless
+ assertThat(counter("l2.get.error")).isEqualTo(1);
+ assertThat(timerCount("l2.get.latency")).isEqualTo(1);
+ }
+
+ @Test
+ public void putShouldRecordStorePutErrorAndLatencyWhenL2PutFails() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.failedFuture(new RuntimeException("down")));
+
+ // when
+ target.put(List.of(IIQ), eids, 1000L);
+
+ // then
+ assertThat(counter("l2.put.error")).isEqualTo(1);
+ assertThat(timerCount("l2.put.latency")).isEqualTo(1);
+ }
+
+ @Test
+ public void shouldExposeL1SizeGaugeReflectingCachedEntries() {
+ // given
+ when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture());
+
+ // when
+ target.put(List.of(IIQ), eids, 60_000L);
+
+ // then — the L1 size gauge is registered and reflects the written entry
+ assertThat(gauge("l1.size")).isEqualTo(1L);
+ assertThat(metricRegistry.getGauges()).containsKey(metricName("l1.eviction"));
+ }
+
+ private long counter(String name) {
+ return metricRegistry.counter(metricName(name)).getCount();
+ }
+
+ private long timerCount(String name) {
+ return metricRegistry.timer(metricName(name)).getCount();
+ }
+
+ private Object gauge(String name) {
+ return metricRegistry.getGauges().get(metricName(name)).getValue();
+ }
+
+ private static String metricName(String name) {
+ return "modules.module.intentiq-identity.custom." + name;
+ }
+
+ private String positiveEntry(long exp) {
+ return MAPPER.encodeToString(IdentityCache.CacheEntry.of(eids, false, false, exp));
+ }
+
+ private String negativeEntry(long exp) {
+ return MAPPER.encodeToString(IdentityCache.CacheEntry.of(List.of(), true, false, exp));
+ }
+
+ private String inProgressEntry(long exp) {
+ return MAPPER.encodeToString(IdentityCache.CacheEntry.of(List.of(), false, true, exp));
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStoreTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStoreTest.java
new file mode 100644
index 00000000000..0f65d37afae
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStoreTest.java
@@ -0,0 +1,128 @@
+package org.prebid.server.hooks.modules.intentiq.identity.cache;
+
+import io.vertx.core.Future;
+import io.vertx.redis.client.RedisAPI;
+import io.vertx.redis.client.Response;
+import io.vertx.redis.client.impl.types.SimpleStringType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class RedisIdentityStoreTest {
+
+ @Mock
+ private RedisAPI redis;
+
+ @Captor
+ private ArgumentCaptor> setArgsCaptor;
+
+ private RedisIdentityStore target;
+
+ @BeforeEach
+ public void setUp() {
+ target = new RedisIdentityStore(redis);
+ }
+
+ @Test
+ public void putShouldIssueSetWithPxTtl() {
+ // given
+ when(redis.set(any())).thenReturn(Future.succeededFuture());
+
+ // when
+ target.put("key", "value", 1000L);
+
+ // then
+ verify(redis).set(setArgsCaptor.capture());
+ assertThat(setArgsCaptor.getValue()).containsExactly("key", "value", "PX", "1000");
+ }
+
+ @Test
+ public void getShouldReturnResponseAsString() {
+ // given
+ when(redis.get("key")).thenReturn(Future.succeededFuture(SimpleStringType.create("value")));
+
+ // when and then
+ assertThat(target.get("key").result()).isEqualTo("value");
+ }
+
+ @Test
+ public void getShouldReturnNullWhenKeyAbsent() {
+ // given
+ when(redis.get("key")).thenReturn(Future.succeededFuture(null));
+
+ // when and then
+ assertThat(target.get("key").result()).isNull();
+ }
+
+ @Test
+ public void dbSizeShouldReturnResponseAsLong() {
+ // given
+ final Response response = mock(Response.class);
+ when(response.toLong()).thenReturn(42L);
+ when(redis.dbsize()).thenReturn(Future.succeededFuture(response));
+
+ // when and then
+ assertThat(target.dbSize().result()).isEqualTo(42L);
+ }
+
+ @Test
+ public void dbSizeShouldReturnZeroWhenResponseNull() {
+ // given
+ when(redis.dbsize()).thenReturn(Future.succeededFuture(null));
+
+ // when and then
+ assertThat(target.dbSize().result()).isZero();
+ }
+
+ @Test
+ public void evictedKeysShouldParseValueFromInfoStats() {
+ // given
+ when(redis.info(List.of("stats"))).thenReturn(Future.succeededFuture(
+ SimpleStringType.create("# Stats\r\nexpired_keys:5\r\nevicted_keys:17\r\nkeyspace_hits:1\r\n")));
+
+ // when and then
+ assertThat(target.evictedKeys().result()).isEqualTo(17L);
+ }
+
+ @Test
+ public void evictedKeysShouldReturnZeroWhenResponseNull() {
+ // given
+ when(redis.info(List.of("stats"))).thenReturn(Future.succeededFuture(null));
+
+ // when and then
+ assertThat(target.evictedKeys().result()).isZero();
+ }
+
+ @Test
+ public void evictedKeysShouldReturnZeroWhenFieldMissing() {
+ // given
+ when(redis.info(List.of("stats"))).thenReturn(Future.succeededFuture(
+ SimpleStringType.create("# Stats\r\nexpired_keys:5\r\nkeyspace_hits:1\r\n")));
+
+ // when and then
+ assertThat(target.evictedKeys().result()).isZero();
+ }
+
+ @Test
+ public void evictedKeysShouldReturnZeroWhenValueNotANumber() {
+ // given
+ when(redis.info(List.of("stats"))).thenReturn(Future.succeededFuture(
+ SimpleStringType.create("evicted_keys:not-a-number\r\n")));
+
+ // when and then
+ assertThat(target.evictedKeys().result()).isZero();
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporterTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporterTest.java
new file mode 100644
index 00000000000..37e69ca5857
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporterTest.java
@@ -0,0 +1,133 @@
+package org.prebid.server.hooks.modules.intentiq.identity.cache;
+
+import io.vertx.core.Future;
+import io.vertx.core.Handler;
+import io.vertx.core.Vertx;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+
+import java.util.function.LongSupplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class RedisStatsReporterTest {
+
+ @Mock
+ private RedisIdentityStore store;
+
+ @Mock
+ private Vertx vertx;
+
+ @Mock
+ private IntentiqIdentityMetrics metrics;
+
+ @Captor
+ private ArgumentCaptor sizeSupplierCaptor;
+
+ @Captor
+ private ArgumentCaptor evictionsSupplierCaptor;
+
+ @Captor
+ private ArgumentCaptor> periodicHandlerCaptor;
+
+ @Test
+ public void constructorShouldThrowWhenStoreIsNull() {
+ // when and then
+ assertThatNullPointerException()
+ .isThrownBy(() -> new RedisStatsReporter(null, vertx, metrics, 1000L));
+ }
+
+ @Test
+ public void constructorShouldThrowWhenVertxIsNull() {
+ // when and then
+ assertThatNullPointerException()
+ .isThrownBy(() -> new RedisStatsReporter(store, null, metrics, 1000L));
+ }
+
+ @Test
+ public void constructorShouldRegisterGaugesThatReadTheLatestPolledValues() {
+ // given
+ when(store.dbSize()).thenReturn(Future.succeededFuture(7L));
+ when(store.evictedKeys()).thenReturn(Future.succeededFuture(3L));
+
+ // when
+ new RedisStatsReporter(store, vertx, metrics, 1000L).start();
+
+ // then
+ verify(metrics).registerL2Gauges(sizeSupplierCaptor.capture(), evictionsSupplierCaptor.capture());
+ assertThat(sizeSupplierCaptor.getValue().getAsLong()).isEqualTo(7L);
+ assertThat(evictionsSupplierCaptor.getValue().getAsLong()).isEqualTo(3L);
+ }
+
+ @Test
+ public void startShouldPollImmediatelyAndScheduleAPeriodicPoll() {
+ // given
+ when(store.dbSize()).thenReturn(Future.succeededFuture(1L));
+ when(store.evictedKeys()).thenReturn(Future.succeededFuture(1L));
+
+ // when
+ new RedisStatsReporter(store, vertx, metrics, 1000L).start();
+
+ // then
+ verify(store).dbSize();
+ verify(store).evictedKeys();
+ verify(vertx).setPeriodic(eq(1000L), any());
+ }
+
+ @Test
+ public void startShouldReturnSelfForFluentWiring() {
+ // given
+ when(store.dbSize()).thenReturn(Future.succeededFuture(0L));
+ when(store.evictedKeys()).thenReturn(Future.succeededFuture(0L));
+ final RedisStatsReporter target = new RedisStatsReporter(store, vertx, metrics, 1000L);
+
+ // when and then
+ assertThat(target.start()).isSameAs(target);
+ }
+
+ @Test
+ public void periodicTickShouldPollAgain() {
+ // given
+ when(store.dbSize()).thenReturn(Future.succeededFuture(1L));
+ when(store.evictedKeys()).thenReturn(Future.succeededFuture(1L));
+ when(vertx.setPeriodic(eq(1000L), periodicHandlerCaptor.capture())).thenReturn(1L);
+
+ new RedisStatsReporter(store, vertx, metrics, 1000L).start();
+
+ // when
+ periodicHandlerCaptor.getValue().handle(1L);
+
+ // then
+ verify(store, times(2)).dbSize();
+ verify(store, times(2)).evictedKeys();
+ }
+
+ @Test
+ public void pollShouldLeaveGaugesAtZeroWhenBothProbesFail() {
+ // given
+ lenient().when(vertx.setPeriodic(eq(1000L), any())).thenReturn(1L);
+ when(store.dbSize()).thenReturn(Future.failedFuture(new RuntimeException("dbsize down")));
+ when(store.evictedKeys()).thenReturn(Future.failedFuture(new RuntimeException("info down")));
+
+ // when
+ new RedisStatsReporter(store, vertx, metrics, 1000L).start();
+
+ // then
+ verify(metrics).registerL2Gauges(sizeSupplierCaptor.capture(), evictionsSupplierCaptor.capture());
+ assertThat(sizeSupplierCaptor.getValue().getAsLong()).isZero();
+ assertThat(evictionsSupplierCaptor.getValue().getAsLong()).isZero();
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfigTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfigTest.java
new file mode 100644
index 00000000000..60c37592e77
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfigTest.java
@@ -0,0 +1,175 @@
+package org.prebid.server.hooks.modules.intentiq.identity.config;
+
+import com.codahale.metrics.MetricRegistry;
+import io.vertx.core.Future;
+import io.vertx.core.Vertx;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityCache;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityStore;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.RedisIdentityStore;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.RedisStatsReporter;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.NoopIntentiqIdentityMetrics;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.RedisProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityModule;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.json.JsonMerger;
+import org.prebid.server.json.ObjectMapperProvider;
+import org.prebid.server.vertx.httpclient.HttpClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class IntentiqIdentityConfigTest {
+
+ private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper());
+
+ @Mock
+ private HttpClient httpClient;
+
+ @Mock
+ private Vertx vertx;
+
+ private final IntentiqIdentityConfig target = new IntentiqIdentityConfig();
+
+ @Test
+ public void propertiesBeanShouldBeCreated() {
+ assertThat(target.intentiqIdentityProperties()).isInstanceOf(IntentiqIdentityProperties.class);
+ }
+
+ @Test
+ public void configResolverBeanShouldBeCreated() {
+ assertThat(target.intentiqIdentityConfigResolver(new JsonMerger(MAPPER), new IntentiqIdentityProperties()))
+ .isInstanceOf(ConfigResolver.class);
+ }
+
+ @Test
+ public void moduleBeanShouldExposeProcessedAuctionRequestHookUnderModuleCode() {
+ // given
+ final ConfigResolver configResolver =
+ new ConfigResolver(MAPPER.mapper(), new JsonMerger(MAPPER), new IntentiqIdentityProperties());
+
+ // when
+ final IntentiqIdentityModule module = target.intentiqIdentityModule(
+ configResolver,
+ httpClient,
+ MAPPER,
+ new IntentiqIdentityMetrics(new MetricRegistry()),
+ new IntentiqIdentityProperties(),
+ mock(IdentityCache.class));
+
+ // then
+ assertThat(module.code()).isEqualTo(IntentiqIdentityModule.CODE);
+ assertThat(module.hooks()).hasSize(2);
+ }
+
+ @Test
+ public void metricsBeanShouldBeCreated() {
+ assertThat(target.intentiqIdentityMetrics(new MetricRegistry()))
+ .isInstanceOf(IntentiqIdentityMetrics.class);
+ }
+
+ @Test
+ public void noopMetricsBeanShouldBeCreated() {
+ assertThat(target.noopIntentiqIdentityMetrics())
+ .isInstanceOf(NoopIntentiqIdentityMetrics.class);
+ }
+
+ @Test
+ public void identityStoreBeanShouldFailWhenRedisHostMissing() {
+ // given
+ final IntentiqIdentityProperties properties = new IntentiqIdentityProperties();
+ properties.setRedis(new RedisProperties());
+
+ // when and then
+ assertThatThrownBy(() -> target.intentiqIdentityStore(properties, vertx))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ public void identityStoreBeanShouldFailWhenRedisPortMissing() {
+ // given
+ final RedisProperties redis = new RedisProperties();
+ redis.setHost("localhost");
+ final IntentiqIdentityProperties properties = new IntentiqIdentityProperties();
+ properties.setRedis(redis);
+
+ // when and then
+ assertThatThrownBy(() -> target.intentiqIdentityStore(properties, vertx))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ public void redisStatsReporterBeanShouldReturnReporterWhenStoreIsRedis() {
+ // given
+ final RedisIdentityStore store = mock(RedisIdentityStore.class);
+ when(store.dbSize()).thenReturn(Future.succeededFuture(0L));
+ when(store.evictedKeys()).thenReturn(Future.succeededFuture(0L));
+
+ // when
+ final RedisStatsReporter reporter = target.intentiqIdentityRedisStatsReporter(
+ store, vertx, new IntentiqIdentityMetrics(new MetricRegistry()));
+
+ // then
+ assertThat(reporter).isNotNull();
+ }
+
+ @Test
+ public void redisStatsReporterBeanShouldReturnNullWhenStoreIsNotRedis() {
+ // when and then — a custom (non-Redis) IdentityStore has no DBSIZE/INFO stats to poll
+ assertThat(target.intentiqIdentityRedisStatsReporter(
+ mock(IdentityStore.class), vertx, new IntentiqIdentityMetrics(new MetricRegistry())))
+ .isNull();
+ }
+
+ @Test
+ public void identityStoreBeanShouldBeBuiltWhenRedisPasswordConfigured() {
+ // given — a real Vertx is required because the Redis client casts it to VertxInternal
+ final Vertx realVertx = Vertx.vertx();
+ final RedisProperties redis = new RedisProperties();
+ redis.setHost("redis.internal");
+ redis.setPort(6390);
+ redis.setPassword("s3cret");
+ final IntentiqIdentityProperties properties = new IntentiqIdentityProperties();
+ properties.setRedis(redis);
+
+ try {
+ // when and then
+ assertThat(target.intentiqIdentityStore(properties, realVertx)).isNotNull();
+ } finally {
+ realVertx.close();
+ }
+ }
+
+ @Test
+ public void identityStoreAndCacheBeansShouldBeBuiltWhenRedisConfigured() {
+ // given — a real Vertx is required because the Redis client casts it to VertxInternal
+ final Vertx realVertx = Vertx.vertx();
+ final RedisProperties redis = new RedisProperties();
+ redis.setHost("redis.internal");
+ redis.setPort(6390);
+ final IntentiqIdentityProperties properties = new IntentiqIdentityProperties();
+ properties.setRedis(redis);
+
+ try {
+ // when
+ final IdentityStore store = target.intentiqIdentityStore(properties, realVertx);
+ final IdentityCache cache = target.intentiqIdentityCache(
+ properties, store, MAPPER, new IntentiqIdentityMetrics(new MetricRegistry()));
+
+ // then
+ assertThat(store).isNotNull();
+ assertThat(cache).isNotNull();
+ } finally {
+ realVertx.close();
+ }
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetricsTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetricsTest.java
new file mode 100644
index 00000000000..ebbeaad774e
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetricsTest.java
@@ -0,0 +1,165 @@
+package org.prebid.server.hooks.modules.intentiq.identity.metric;
+
+import com.codahale.metrics.MetricRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class IntentiqIdentityMetricsTest {
+
+ private static final String DPI = "15769";
+
+ private MetricRegistry metricRegistry;
+ private IntentiqIdentityMetrics target;
+
+ @BeforeEach
+ public void setUp() {
+ metricRegistry = new MetricRegistry();
+ target = new IntentiqIdentityMetrics(metricRegistry);
+ }
+
+ @Test
+ public void shouldIncrementNamespacedCountersSuffixedWithDpi() {
+ // when
+ target.cacheHit("l1", "third_party", DPI);
+ target.cacheHit("l2", "third_party", DPI);
+ target.cacheNegativeHit("l2", "device", DPI);
+ target.cacheInProgress("l1", "device", DPI);
+ target.cacheMiss("third_party", DPI);
+ target.cacheMiss("device", DPI);
+ target.apiSuccess(DPI);
+ target.apiError(DPI);
+ target.enriched(DPI);
+ target.eidsNone(DPI);
+ target.skipNoEndpoint(DPI);
+ target.impressionReported(DPI);
+ target.impressionError(DPI);
+
+ // then
+ assertThat(count("cache.l1.hit.third_party_" + DPI)).isEqualTo(1);
+ assertThat(count("cache.l2.hit.third_party_" + DPI)).isEqualTo(1);
+ assertThat(count("cache.l2.negative.hit.device_" + DPI)).isEqualTo(1);
+ assertThat(count("cache.l1.in_progress.device_" + DPI)).isEqualTo(1);
+ assertThat(count("cache.miss.third_party_" + DPI)).isEqualTo(1);
+ assertThat(count("cache.miss.device_" + DPI)).isEqualTo(1);
+ assertThat(count("api.success_" + DPI)).isEqualTo(1);
+ assertThat(count("api.error_" + DPI)).isEqualTo(1);
+ assertThat(count("enriched_" + DPI)).isEqualTo(1);
+ assertThat(count("eids.none_" + DPI)).isEqualTo(1);
+ assertThat(count("skip.no_endpoint_" + DPI)).isEqualTo(1);
+ assertThat(count("impression.reported_" + DPI)).isEqualTo(1);
+ assertThat(count("impression.error_" + DPI)).isEqualTo(1);
+ }
+
+ @Test
+ public void shouldRecordNothingWhenDisabled() {
+ // given
+ final IntentiqIdentityMetrics disabled = new NoopIntentiqIdentityMetrics();
+
+ // when
+ disabled.cacheHit("l1", "third_party", DPI);
+ disabled.terminationCause(20, DPI);
+ disabled.apiLatency(1_000_000L, DPI);
+
+ // then
+ assertThat(metricRegistry.getMetrics()).isEmpty();
+ }
+
+ @Test
+ public void shouldOmitDpiSuffixWhenDpiBlank() {
+ // when
+ target.enriched(null);
+ target.enriched("");
+
+ // then
+ assertThat(count("enriched")).isEqualTo(2);
+ }
+
+ @Test
+ public void shouldIncrementTerminationCauseCounterByRawCauseId() {
+ // when
+ target.terminationCause(20, DPI);
+ target.terminationCause(20, DPI);
+ target.terminationCause(36, DPI);
+
+ // then
+ assertThat(count("tc.20_" + DPI)).isEqualTo(2);
+ assertThat(count("tc.36_" + DPI)).isEqualTo(1);
+ }
+
+ @Test
+ public void shouldDropOutOfRangeTerminationCauses() {
+ // when — out-of-range values emit no counter at all
+ target.terminationCause(3333, DPI);
+ target.terminationCause(120088, DPI);
+ target.terminationCause(49, DPI);
+
+ // then
+ assertThat(count("tc.3333_" + DPI)).isZero();
+ assertThat(count("tc.120088_" + DPI)).isZero();
+ assertThat(count("tc.49_" + DPI)).isEqualTo(1);
+ }
+
+ @Test
+ public void shouldRecordApiLatencyTimer() {
+ // when
+ target.apiLatency(1_000_000L, DPI);
+ target.apiLatency(3_000_000L, DPI);
+
+ // then
+ assertThat(metricRegistry.timer("modules.module.intentiq-identity.custom.api.latency_" + DPI).getCount())
+ .isEqualTo(2);
+ }
+
+ @Test
+ public void shouldRecordL1L2HealthMetricsGloballyWithoutDpiSuffix() {
+ // when — shared L1/L2 health metrics carry no _ segment
+ target.l1GetError();
+ target.l1PutError();
+ target.l2GetError();
+ target.l2PutError();
+ target.l2PutError();
+ target.l2GetLatency(1_000_000L);
+ target.l2PutLatency(2_000_000L);
+ target.registerL1Gauges(() -> 7L, () -> 3L);
+ target.registerL2Gauges(() -> 100L, () -> 9L);
+
+ // then
+ assertThat(count("l1.get.error")).isEqualTo(1);
+ assertThat(count("l1.put.error")).isEqualTo(1);
+ assertThat(count("l2.get.error")).isEqualTo(1);
+ assertThat(count("l2.put.error")).isEqualTo(2);
+ assertThat(metricRegistry.timer("modules.module.intentiq-identity.custom.l2.get.latency").getCount())
+ .isEqualTo(1);
+ assertThat(metricRegistry.timer("modules.module.intentiq-identity.custom.l2.put.latency").getCount())
+ .isEqualTo(1);
+ assertThat(gauge("l1.size")).isEqualTo(7L);
+ assertThat(gauge("l1.eviction")).isEqualTo(3L);
+ assertThat(gauge("l2.size")).isEqualTo(100L);
+ assertThat(gauge("l2.eviction")).isEqualTo(9L);
+ }
+
+ @Test
+ public void shouldRecordNoHealthMetricsWhenDisabled() {
+ // given
+ final IntentiqIdentityMetrics disabled = new NoopIntentiqIdentityMetrics();
+
+ // when
+ disabled.l2GetError();
+ disabled.l1PutError();
+ disabled.l2GetLatency(1_000_000L);
+ disabled.registerL1Gauges(() -> 7L, () -> 3L);
+
+ // then
+ assertThat(metricRegistry.getMetrics()).isEmpty();
+ }
+
+ private Object gauge(String name) {
+ return metricRegistry.getGauges().get("modules.module.intentiq-identity.custom." + name).getValue();
+ }
+
+ private long count(String name) {
+ return metricRegistry.counter("modules.module.intentiq-identity.custom." + name).getCount();
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHookTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHookTest.java
new file mode 100644
index 00000000000..31b85ce4833
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHookTest.java
@@ -0,0 +1,233 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1;
+
+import com.codahale.metrics.MetricRegistry;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.Site;
+import com.iab.openrtb.response.Bid;
+import com.iab.openrtb.response.BidResponse;
+import com.iab.openrtb.response.SeatBid;
+import io.vertx.core.Future;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.hooks.execution.v1.auction.AuctionResponsePayloadImpl;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.hooks.modules.intentiq.identity.model.IntentiqIdentityModuleContext;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver;
+import org.prebid.server.hooks.v1.InvocationAction;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.auction.AuctionResponsePayload;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.json.JsonMerger;
+import org.prebid.server.json.ObjectMapperProvider;
+import org.prebid.server.vertx.httpclient.HttpClient;
+import org.prebid.server.vertx.httpclient.model.HttpClientResponse;
+
+import java.math.BigDecimal;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class IntentiqIdentityAuctionResponseHookTest {
+
+ private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper());
+ private static final JsonMerger MERGER = new JsonMerger(MAPPER);
+ private static final String REPORTS = "https://reports.example.com/report";
+
+ @Mock
+ private HttpClient httpClient;
+
+ @Mock
+ private AuctionInvocationContext invocationContext;
+
+ @Mock
+ private AuctionContext auctionContext;
+
+ @Captor
+ private ArgumentCaptor urlCaptor;
+
+ private final MetricRegistry metricRegistry = new MetricRegistry();
+ private final IntentiqIdentityMetrics metrics = new IntentiqIdentityMetrics(metricRegistry);
+
+ @BeforeEach
+ public void setUp() {
+ when(invocationContext.accountConfig()).thenReturn(MAPPER.mapper().createObjectNode());
+ when(invocationContext.auctionContext()).thenReturn(auctionContext);
+ when(invocationContext.moduleContext()).thenReturn(null);
+ when(httpClient.get(urlCaptor.capture(), any(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, null, "{}")));
+ }
+
+ private IntentiqIdentityAuctionResponseHook target(String reportsEndpoint, String partnerId) {
+ final IntentiqIdentityProperties properties = new IntentiqIdentityProperties();
+ properties.setReportsEndpoint(reportsEndpoint);
+ properties.setPartnerId(partnerId);
+ properties.setTimeout(1000L);
+ final ConfigResolver configResolver = new ConfigResolver(MAPPER.mapper(), MERGER, properties);
+ return new IntentiqIdentityAuctionResponseHook(configResolver, httpClient, MAPPER, metrics);
+ }
+
+ private AuctionResponsePayload payload(BidResponse bidResponse) {
+ return AuctionResponsePayloadImpl.of(bidResponse);
+ }
+
+ private void givenRequest(BidRequest bidRequest) {
+ when(auctionContext.getBidRequest()).thenReturn(bidRequest);
+ }
+
+ private static BidResponse response(String seat, Bid... bids) {
+ return BidResponse.builder()
+ .cur("USD")
+ .seatbid(List.of(SeatBid.builder().seat(seat).bid(List.of(bids)).build()))
+ .build();
+ }
+
+ private static String rdataJson(String url) {
+ final String marker = "rdata=";
+ final String encoded = url.substring(url.indexOf(marker) + marker.length());
+ return URLDecoder.decode(encoded, StandardCharsets.UTF_8);
+ }
+
+ @Test
+ public void callShouldReportWinningBidWithRdataAndConstants() {
+ // given
+ givenRequest(BidRequest.builder()
+ .site(Site.builder().domain("test.com").build())
+ .device(Device.builder().ip("1.2.3.4").ua("UA").build())
+ .build());
+ when(invocationContext.moduleContext())
+ .thenReturn(new IntentiqIdentityModuleContext(System.nanoTime(), "ab-123", null));
+ final Bid bid = Bid.builder().impid("imp1").price(BigDecimal.valueOf(1.18)).build();
+
+ // when
+ target(REPORTS, "999").call(payload(response("pubmatic", bid)), invocationContext);
+
+ // then
+ final String url = urlCaptor.getValue();
+ assertThat(url).startsWith(REPORTS + "?at=45&rtype=1&source=pbjv&dpi=999&rdata=");
+ assertThat(url).contains("pubmatic").contains("imp1").contains("ab-123").contains("test.com");
+ assertThat(metricRegistry.counter("modules.module.intentiq-identity.custom.impression.reported_999")
+ .getCount())
+ .isEqualTo(1);
+ }
+
+ @Test
+ public void callShouldReportEachWinningBid() {
+ // given
+ givenRequest(BidRequest.builder().build());
+ final Bid bid1 = Bid.builder().impid("a").price(BigDecimal.ONE).build();
+ final Bid bid2 = Bid.builder().impid("b").price(BigDecimal.TEN).build();
+
+ // when
+ target(REPORTS, "999").call(payload(response("seat", bid1, bid2)), invocationContext);
+
+ // then
+ verify(httpClient, times(2)).get(any(), any(), anyLong());
+ }
+
+ @Test
+ public void callShouldOmitAbTestUuidWhenAbsent() {
+ // given
+ givenRequest(BidRequest.builder().build());
+ final Bid bid = Bid.builder().impid("a").price(BigDecimal.ONE).build();
+
+ // when
+ target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue()).doesNotContain("abTestUuid");
+ }
+
+ @Test
+ public void callShouldIncludeOriginalCpmAndCurrencyFromBidExt() {
+ // given
+ givenRequest(BidRequest.builder().build());
+ final ObjectNode ext = MAPPER.mapper().createObjectNode();
+ ext.put("origbidcpm", new BigDecimal("2.50"));
+ ext.put("origbidcur", "EUR");
+ final Bid bid = Bid.builder().impid("a").price(BigDecimal.valueOf(1.18)).ext(ext).build();
+
+ // when
+ target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext);
+
+ // then
+ final String rdata = rdataJson(urlCaptor.getValue());
+ assertThat(rdata).contains("\"originalCpm\":2.50").contains("\"originalCurrency\":\"EUR\"");
+ }
+
+ @Test
+ public void callShouldIncludePartnerAuctionIdFromRequestId() {
+ // given
+ givenRequest(BidRequest.builder().id("auction-77").build());
+ final Bid bid = Bid.builder().impid("a").price(BigDecimal.ONE).build();
+
+ // when
+ target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext);
+
+ // then
+ assertThat(rdataJson(urlCaptor.getValue())).contains("\"partnerAuctionId\":\"auction-77\"");
+ }
+
+ @Test
+ public void callShouldIncludeTerminationCauseFromModuleContext() {
+ // given
+ givenRequest(BidRequest.builder().build());
+ when(invocationContext.moduleContext())
+ .thenReturn(new IntentiqIdentityModuleContext(System.nanoTime(), "ab-1", 7L));
+ final Bid bid = Bid.builder().impid("a").price(BigDecimal.ONE).build();
+
+ // when
+ target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext);
+
+ // then
+ assertThat(rdataJson(urlCaptor.getValue())).contains("\"terminationCause\":7");
+ }
+
+ @Test
+ public void callShouldOmitTerminationCauseWhenModuleContextHasNone() {
+ // given
+ givenRequest(BidRequest.builder().build());
+ when(invocationContext.moduleContext())
+ .thenReturn(new IntentiqIdentityModuleContext(System.nanoTime(), "ab-1", null));
+ final Bid bid = Bid.builder().impid("a").price(BigDecimal.ONE).build();
+
+ // when
+ target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext);
+
+ // then
+ assertThat(rdataJson(urlCaptor.getValue())).doesNotContain("terminationCause");
+ }
+
+ @Test
+ public void callShouldReturnNoActionAndSkipReportingWhenReportsEndpointBlank() {
+ // when
+ final var result = target(null, "999")
+ .call(payload(response("seat", Bid.builder().impid("a").price(BigDecimal.ONE).build())),
+ invocationContext)
+ .result();
+
+ // then
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ verifyNoInteractions(httpClient);
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModuleTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModuleTest.java
new file mode 100644
index 00000000000..81bde2b0a1d
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModuleTest.java
@@ -0,0 +1,55 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1;
+
+import com.codahale.metrics.MetricRegistry;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.FirstPartyKeyExtractor;
+import org.prebid.server.hooks.v1.Hook;
+import org.prebid.server.hooks.v1.InvocationContext;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.json.JsonMerger;
+import org.prebid.server.json.ObjectMapperProvider;
+import org.prebid.server.vertx.httpclient.HttpClient;
+
+import java.util.List;
+
+import static java.util.Collections.emptyList;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@ExtendWith(MockitoExtension.class)
+public class IntentiqIdentityModuleTest {
+
+ private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper());
+
+ @Mock
+ private HttpClient httpClient;
+
+ @Test
+ public void codeShouldReturnExpectedValue() {
+ // given
+ final IntentiqIdentityModule target = new IntentiqIdentityModule(emptyList());
+
+ // when and then
+ assertThat(target.code()).isEqualTo("intentiq-identity");
+ }
+
+ @Test
+ public void hooksShouldReturnProvidedHooks() {
+ // given
+ final ConfigResolver configResolver =
+ new ConfigResolver(MAPPER.mapper(), new JsonMerger(MAPPER), new IntentiqIdentityProperties());
+ final List extends Hook, ? extends InvocationContext>> hooks = List.of(
+ new IntentiqIdentityProcessedAuctionRequestHook(
+ configResolver, httpClient, MAPPER, null,
+ new FirstPartyKeyExtractor(10), new IntentiqIdentityMetrics(new MetricRegistry())));
+ final IntentiqIdentityModule target = new IntentiqIdentityModule(hooks);
+
+ // when and then
+ assertThat(target.hooks()).isEqualTo(hooks);
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHookTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHookTest.java
new file mode 100644
index 00000000000..241fcb36258
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHookTest.java
@@ -0,0 +1,750 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1;
+
+import com.codahale.metrics.MetricRegistry;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.BrandVersion;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Regs;
+import com.iab.openrtb.request.Site;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import com.iab.openrtb.request.UserAgent;
+import org.prebid.server.proto.openrtb.ext.request.ExtRegs;
+import org.prebid.server.proto.openrtb.ext.request.ExtUser;
+import io.vertx.core.Future;
+import io.vertx.core.MultiMap;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheResult;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityCache;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.KeyType;
+import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics;
+import org.prebid.server.hooks.modules.intentiq.identity.model.IntentiqIdentityModuleContext;
+import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver;
+import org.prebid.server.hooks.modules.intentiq.identity.v1.core.FirstPartyKeyExtractor;
+import org.prebid.server.hooks.v1.InvocationAction;
+import org.prebid.server.hooks.v1.InvocationResult;
+import org.prebid.server.hooks.v1.InvocationStatus;
+import org.prebid.server.hooks.v1.PayloadUpdate;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.json.JsonMerger;
+import org.prebid.server.json.ObjectMapperProvider;
+import org.prebid.server.vertx.httpclient.HttpClient;
+import org.prebid.server.vertx.httpclient.model.HttpClientResponse;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class IntentiqIdentityProcessedAuctionRequestHookTest {
+
+ private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper());
+ private static final JsonMerger MERGER = new JsonMerger(MAPPER);
+ private static final String API_ENDPOINT = "https://dev.example.com/resolve";
+ private static final String EIDS_BODY =
+ "{\"data\":{\"eids\":[{\"source\":\"intentiq.com\",\"uids\":[{\"id\":\"abc\",\"atype\":1}]}]}}";
+ private static final String EMPTY_BODY = "{\"data\":{\"eids\":[]}}";
+
+ @Mock
+ private HttpClient httpClient;
+
+ @Mock
+ private AuctionInvocationContext invocationContext;
+
+ @Captor
+ private ArgumentCaptor urlCaptor;
+
+ @Captor
+ private ArgumentCaptor headersCaptor;
+
+ private final MetricRegistry metricRegistry = new MetricRegistry();
+ private final IntentiqIdentityMetrics metrics = new IntentiqIdentityMetrics(metricRegistry);
+
+ @BeforeEach
+ public void setUp() {
+ when(invocationContext.accountConfig()).thenReturn(MAPPER.mapper().createObjectNode());
+ }
+
+ // Counter-asserting tests all run with partner id "123"; metrics are suffixed with the dpi.
+ private long counter(String name) {
+ return metricRegistry.counter("modules.module.intentiq-identity.custom." + name + "_123").getCount();
+ }
+
+ private IntentiqIdentityProcessedAuctionRequestHook target(String apiEndpoint, String partnerId) {
+ return target(apiEndpoint, partnerId, null);
+ }
+
+ private IntentiqIdentityProcessedAuctionRequestHook target(String apiEndpoint, String partnerId,
+ IdentityCache cache) {
+ final IntentiqIdentityProperties properties = new IntentiqIdentityProperties();
+ properties.setApiEndpoint(apiEndpoint);
+ properties.setPartnerId(partnerId);
+ properties.setTimeout(1000L);
+ if (cache != null) {
+ properties.getCache().setEnabled(true);
+ }
+ final ConfigResolver configResolver = new ConfigResolver(MAPPER.mapper(), MERGER, properties);
+ return new IntentiqIdentityProcessedAuctionRequestHook(
+ configResolver, httpClient, MAPPER, cache, new FirstPartyKeyExtractor(10), metrics);
+ }
+
+ @Test
+ public void codeShouldReturnExpectedValue() {
+ assertThat(target(API_ENDPOINT, "123").code())
+ .isEqualTo("intentiq-identity-processed-auction-request-hook");
+ }
+
+ @Test
+ public void callShouldEnrichUserEidsWhenApiReturnsEids() {
+ // given
+ givenResponse(EIDS_BODY);
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build());
+
+ // when
+ final InvocationResult result = target(API_ENDPOINT, "123")
+ .call(payload, invocationContext).result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.update);
+ final Eid enriched = applyUpdate(result, payload).getUser().getEids().getFirst();
+ assertThat(enriched.getSource()).isEqualTo("intentiq.com");
+ assertThat(enriched.getUids().getFirst().getId()).isEqualTo("abc");
+ }
+
+ @Test
+ public void callShouldFallBackToGlobalPropertiesWhenAccountConfigIsNull() {
+ // given — host-level-only config: no account override, so accountConfig() is null in production
+ // (JsonMergePatch rejects a null patch, which previously failed the whole hook invocation).
+ givenResponse(EIDS_BODY);
+ when(invocationContext.accountConfig()).thenReturn(null);
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build());
+
+ // when
+ final InvocationResult result = target(API_ENDPOINT, "123")
+ .call(payload, invocationContext).result();
+
+ // then — resolver falls back to global properties and the hook still enriches
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.update);
+ assertThat(applyUpdate(result, payload).getUser().getEids().getFirst().getSource())
+ .isEqualTo("intentiq.com");
+ }
+
+ @Test
+ public void callShouldAppendResolvedEidsAfterExistingUserEids() {
+ // given
+ givenResponse(EIDS_BODY);
+ final Eid existing = Eid.builder()
+ .source("pubcid.org")
+ .uids(singletonList(Uid.builder().id("existing-uid").build()))
+ .build();
+ final BidRequest bidRequest = BidRequest.builder()
+ .user(User.builder().eids(singletonList(existing)).build())
+ .build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ // when
+ final InvocationResult result = target(API_ENDPOINT, "123")
+ .call(payload, invocationContext).result();
+
+ // then
+ assertThat(applyUpdate(result, payload).getUser().getEids())
+ .extracting(Eid::getSource)
+ .containsExactly("pubcid.org", "intentiq.com");
+ }
+
+ @Test
+ public void callShouldSendGetWithConstantsPartnerAndSrvrReq() {
+ // given
+ givenCapturedResponse();
+
+ // when
+ target(API_ENDPOINT, "partner-42").call(
+ AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue())
+ .isEqualTo(API_ENDPOINT + "?at=39&mi=10&dpi=partner-42&pt=17&dpn=1&srvrReq=true&source=pbjv");
+ }
+
+ @Test
+ public void callShouldApplyAccountLevelPartnerIdOverride() {
+ // given
+ givenCapturedResponse();
+ final ObjectNode account = MAPPER.mapper().createObjectNode().put("partner-id", "acct-9");
+ when(invocationContext.accountConfig()).thenReturn(account);
+
+ // when
+ target(API_ENDPOINT, "global-1").call(
+ AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext);
+
+ // then — account-level partner-id wins over the global value
+ assertThat(urlCaptor.getValue()).contains("&dpi=acct-9");
+ }
+
+ @Test
+ public void callShouldAppendDeviceIpIpv6AndUserAgent() {
+ // given
+ givenCapturedResponse();
+ final BidRequest bidRequest = BidRequest.builder()
+ .device(Device.builder().ip("125.253.50.47").ipv6("2001:db8::1").ua("Mozilla/5.0 (iPhone)").build())
+ .build();
+
+ // when
+ target(API_ENDPOINT, "383342646").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue())
+ .isEqualTo(API_ENDPOINT + "?at=39&mi=10&dpi=383342646&pt=17&dpn=1&srvrReq=true&source=pbjv"
+ + "&ip=125.253.50.47&ipv6=2001%3Adb8%3A%3A1&uas=Mozilla%2F5.0%20%28iPhone%29");
+ }
+
+ @Test
+ public void callShouldSendDeviceIfaAsPcidWithIdtype4ForMobile() {
+ // given
+ givenCapturedResponse();
+ final BidRequest bidRequest = BidRequest.builder()
+ .device(Device.builder().ifa("maid-AbC").devicetype(1).build())
+ .build();
+
+ // when
+ target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue()).contains("&pcid=maid-AbC&idtype=4");
+ }
+
+ @Test
+ public void callShouldUppercasePcidAndSendIdtype8ForCtv() {
+ // given
+ givenCapturedResponse();
+ final BidRequest bidRequest = BidRequest.builder()
+ .device(Device.builder().ifa("rida-abc").devicetype(3).build())
+ .build();
+
+ // when
+ target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue()).contains("&pcid=RIDA-ABC&idtype=8");
+ }
+
+ @Test
+ public void callShouldNotSendPcidWhenLimitAdTracking() {
+ // given
+ givenCapturedResponse();
+ final BidRequest bidRequest = BidRequest.builder()
+ .device(Device.builder().ifa("maid-1").lmt(1).build())
+ .build();
+
+ // when
+ target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue()).doesNotContain("pcid").doesNotContain("idtype");
+ }
+
+ @Test
+ public void callShouldSendSiteDomainAsRef() {
+ // given
+ givenCapturedResponse();
+ final BidRequest bidRequest = BidRequest.builder().site(Site.builder().domain("example.com").build()).build();
+
+ // when
+ target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue()).contains("&ref=example.com");
+ }
+
+ @Test
+ public void callShouldSendExistingIiqUniversalIdAsIiquid() {
+ // given
+ givenCapturedResponse();
+ final BidRequest bidRequest = BidRequest.builder()
+ .user(User.builder().eids(singletonList(Eid.builder()
+ .source("intentiq.com")
+ .uids(singletonList(Uid.builder().id("IIQ-UID-1").build()))
+ .build())).build())
+ .build();
+
+ // when
+ target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue()).contains("&iiquid=IIQ-UID-1");
+ }
+
+ @Test
+ public void callShouldAppendGdprUsPrivacyAndGppAsQueryParamsFromTopLevelFields() {
+ // given
+ givenCapturedResponse();
+ final BidRequest bidRequest = BidRequest.builder()
+ .regs(Regs.builder().gdpr(1).usPrivacy("1YNN").gpp("DBABMA~CONSENT").gppSid(List.of(2, 6)).build())
+ .user(User.builder().consent("CO-TCF-STRING").build())
+ .build();
+
+ // when
+ target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then — consent goes in the gdpr-consent header (GDPR guide), not the query string
+ assertThat(urlCaptor.getValue())
+ .contains("&gdpr=1")
+ .contains("&us_privacy=1YNN")
+ .contains("&gpp=DBABMA%7ECONSENT")
+ .contains("&gpp_sid=2%2C6")
+ .doesNotContain("gdpr_consent");
+ assertThat(headersCaptor.getValue().get("gdpr-consent")).isEqualTo("CO-TCF-STRING");
+ }
+
+ @Test
+ public void callShouldSendConsentInHeaderFromUserExtFallback() {
+ // given
+ givenCapturedResponse();
+ final BidRequest bidRequest = BidRequest.builder()
+ .regs(Regs.builder().ext(ExtRegs.of(1, "1NYN", null, null)).build())
+ .user(User.builder().ext(ExtUser.builder().consent("EXT-TCF-STRING").build()).build())
+ .build();
+
+ // when
+ target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue())
+ .contains("&gdpr=1")
+ .contains("&us_privacy=1NYN")
+ .doesNotContain("gdpr_consent");
+ assertThat(headersCaptor.getValue().get("gdpr-consent")).isEqualTo("EXT-TCF-STRING");
+ }
+
+ @Test
+ public void callShouldNotAppendConsentParamsOrHeaderWhenAbsent() {
+ // given
+ givenCapturedResponse();
+
+ // when
+ target(API_ENDPOINT, "123").call(
+ AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue())
+ .doesNotContain("gdpr")
+ .doesNotContain("us_privacy")
+ .doesNotContain("gpp");
+ assertThat(headersCaptor.getValue().get("gdpr-consent")).isNull();
+ }
+
+ @Test
+ public void callShouldAppendUaHintsFromDeviceSuaInUhParam() throws Exception {
+ // given
+ givenCapturedResponse();
+ final UserAgent sua = UserAgent.builder()
+ .source(2)
+ .browsers(List.of(
+ new BrandVersion("Chromium", List.of("108", "0", "5359", "125"), null),
+ new BrandVersion("Google Chrome", List.of("108", "0", "5359", "125"), null),
+ new BrandVersion("Not?A_Brand", List.of("8", "0", "0", "0"), null)))
+ .platform(new BrandVersion("Windows", List.of("15", "0", "0"), null))
+ .mobile(0)
+ .architecture("x86")
+ .bitness("64")
+ .build();
+ final BidRequest bidRequest = BidRequest.builder().device(Device.builder().sua(sua).build()).build();
+
+ // when
+ target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then — uh is the numeric-keyed UA-CH JSON the IntentIQ backend expects (brands sorted, major vs full version)
+ final JsonNode uh = MAPPER.mapper().readTree(extractParam(urlCaptor.getValue(), "uh"));
+ assertThat(uh.get("0").asText())
+ .isEqualTo("\"Chromium\";v=\"108\", \"Google Chrome\";v=\"108\", \"Not?A_Brand\";v=\"8\"");
+ assertThat(uh.get("8").asText()).isEqualTo("\"Chromium\";v=\"108.0.5359.125\", "
+ + "\"Google Chrome\";v=\"108.0.5359.125\", \"Not?A_Brand\";v=\"8.0.0.0\"");
+ assertThat(uh.get("1").asText()).isEqualTo("?0");
+ assertThat(uh.get("2").asText()).isEqualTo("\"Windows\"");
+ assertThat(uh.get("3").asText()).isEqualTo("\"x86\"");
+ assertThat(uh.get("4").asText()).isEqualTo("\"64\"");
+ assertThat(uh.get("6").asText()).isEqualTo("\"15.0.0\"");
+ assertThat(uh.has("5")).isFalse();
+ assertThat(uh.has("7")).isFalse();
+ }
+
+ @Test
+ public void callShouldNotAppendUhWhenSuaSourceIsNotHighEntropy() {
+ // given — the IntentIQ backend only consumes hints when sua.source == 2 (high-entropy)
+ givenCapturedResponse();
+ final UserAgent sua = UserAgent.builder()
+ .source(1)
+ .browsers(List.of(new BrandVersion("Chrome", List.of("120"), null)))
+ .build();
+ final BidRequest bidRequest = BidRequest.builder().device(Device.builder().sua(sua).build()).build();
+
+ // when
+ target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext);
+
+ // then
+ assertThat(urlCaptor.getValue()).doesNotContain("uh=");
+ }
+
+ @Test
+ public void callShouldCarryTerminationCauseFromResponseInModuleContext() {
+ // given
+ givenResponse("{\"data\":{\"eids\":[{\"source\":\"intentiq.com\",\"uids\":[{\"id\":\"abc\"}]}]},\"tc\":5}");
+
+ // when
+ final InvocationResult result = target(API_ENDPOINT, "123")
+ .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result();
+
+ // then
+ final IntentiqIdentityModuleContext ctx = (IntentiqIdentityModuleContext) result.moduleContext();
+ assertThat(ctx.abTestUuid()).isNull();
+ assertThat(ctx.terminationCause()).isEqualTo(5L);
+ }
+
+ @Test
+ public void callShouldTreatEmptyStringDataAsValidEmptyResponseNotApiError() {
+ // given — GDPR/invalid BE responses return data as an empty string (""), not an object
+ givenResponse("{\"adt\":4,\"ct\":2,\"data\":\"\",\"cttl\":600000,\"tc\":36}");
+
+ // when
+ final InvocationResult result = target(API_ENDPOINT, "123")
+ .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result();
+
+ // then — parsed cleanly as an empty result, not counted as an API error
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ assertThat(counter("api.success")).isEqualTo(1);
+ assertThat(counter("api.error")).isZero();
+ assertThat(counter("eids.none")).isEqualTo(1);
+ assertThat(counter("tc.36")).isEqualTo(1);
+ }
+
+ @Test
+ public void callShouldPassResponseCttlToNegativeCacheOnMiss() {
+ // given — BE supplies the suppression window via cttl on an empty response
+ final IdentityCache cache = mock(IdentityCache.class);
+ when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss()));
+ givenResponse("{\"data\":{\"eids\":[]},\"cttl\":600000}");
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then
+ verify(cache).putNegative(any(), eq(600_000L));
+ }
+
+ @Test
+ public void callShouldReturnNoActionWhenApiReturnsNoEids() {
+ // given
+ givenResponse(EMPTY_BODY);
+
+ // when
+ final InvocationResult result = target(API_ENDPOINT, "123")
+ .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result();
+
+ // then
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ assertThat(result.payloadUpdate()).isNull();
+ }
+
+ @Test
+ public void callShouldReturnNoActionWhenHttpCallFails() {
+ // given
+ when(httpClient.get(any(), any(), anyLong()))
+ .thenReturn(Future.failedFuture(new RuntimeException("boom")));
+
+ // when
+ final Future> future = target(API_ENDPOINT, "123")
+ .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext);
+
+ // then
+ assertThat(future.succeeded()).isTrue();
+ assertThat(future.result().action()).isEqualTo(InvocationAction.no_action);
+ }
+
+ @Test
+ public void callShouldReturnNoActionAndSkipHttpWhenApiEndpointBlank() {
+ // when
+ final InvocationResult result = target(null, "123")
+ .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result();
+
+ // then
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ verifyNoInteractions(httpClient);
+ }
+
+ @Test
+ public void callShouldUseCachedEidsAndSkipHttpOnCacheHit() {
+ // given
+ final IdentityCache cache = mock(IdentityCache.class);
+ final Eid cached = Eid.builder()
+ .source("intentiq.com")
+ .uids(singletonList(Uid.builder().id("cached-uid").build()))
+ .build();
+ when(cache.get(any()))
+ .thenReturn(Future.succeededFuture(
+ CacheResult.hit(singletonList(cached), KeyType.FIRST_PARTY, CacheResult.Layer.L1)));
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ final InvocationResult result =
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then
+ assertThat(result.action()).isEqualTo(InvocationAction.update);
+ assertThat(applyUpdate(result, payload).getUser().getEids()).containsExactly(cached);
+ verifyNoInteractions(httpClient);
+ }
+
+ @Test
+ public void callShouldFetchAndPopulateCacheOnMiss() {
+ // given
+ final IdentityCache cache = mock(IdentityCache.class);
+ when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss()));
+ givenResponse(EIDS_BODY);
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ final InvocationResult result =
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then
+ assertThat(result.action()).isEqualTo(InvocationAction.update);
+ verify(cache).put(any(), any(), anyLong());
+ }
+
+ @Test
+ public void callShouldWriteNegativeCacheEntryWhenApiReturnsNoEidsOnMiss() {
+ // given
+ final IdentityCache cache = mock(IdentityCache.class);
+ when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss()));
+ givenResponse(EMPTY_BODY);
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ final InvocationResult result =
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then — EMPTY_BODY carries no cttl, so the negative entry uses the default suppression TTL
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ verify(cache).putNegative(any(), eq(0L));
+ }
+
+ @Test
+ public void callShouldReturnNoActionAndSkipHttpOnNegativeCacheHit() {
+ // given
+ final IdentityCache cache = mock(IdentityCache.class);
+ when(cache.get(any())).thenReturn(Future.succeededFuture(
+ CacheResult.negative(KeyType.FIRST_PARTY, CacheResult.Layer.L2)));
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ final InvocationResult result =
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ assertThat(counter("cache.l2.negative.hit.first_party")).isEqualTo(1);
+ assertThat(counter("cache.miss.first_party")).isEqualTo(1);
+ assertThat(counter("cache.l1.hit.first_party")).isZero();
+ verifyNoInteractions(httpClient);
+ }
+
+ @Test
+ public void callShouldIncrementApiSuccessAndEnrichedOnEnrichment() {
+ // given
+ givenResponse(EIDS_BODY);
+
+ // when
+ target(API_ENDPOINT, "123").call(
+ AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result();
+
+ // then
+ assertThat(counter("api.success")).isEqualTo(1);
+ assertThat(counter("enriched")).isEqualTo(1);
+ }
+
+ @Test
+ public void callShouldRecordRawTerminationCauseFromResponse() {
+ // given
+ givenResponse("{\"data\":{\"eids\":[]},\"tc\":20}");
+
+ // when
+ target(API_ENDPOINT, "123").call(
+ AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result();
+
+ // then
+ assertThat(counter("tc.20")).isEqualTo(1);
+ }
+
+ @Test
+ public void callShouldDropOutOfRangeTerminationCause() {
+ // given — an out-of-range tc emits no counter
+ givenResponse("{\"data\":{\"eids\":[]},\"tc\":120088}");
+
+ // when
+ target(API_ENDPOINT, "123").call(
+ AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result();
+
+ // then
+ assertThat(counter("tc.120088")).isZero();
+ }
+
+ @Test
+ public void callShouldIncrementApiErrorWhenHttpFails() {
+ // given
+ when(httpClient.get(any(), any(), anyLong()))
+ .thenReturn(Future.failedFuture(new RuntimeException("boom")));
+
+ // when
+ target(API_ENDPOINT, "123").call(
+ AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result();
+
+ // then
+ assertThat(counter("api.error")).isEqualTo(1);
+ }
+
+ @Test
+ public void callShouldIncrementCacheHitOnCacheHit() {
+ // given
+ final IdentityCache cache = mock(IdentityCache.class);
+ when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.hit(singletonList(Eid.builder()
+ .source("intentiq.com")
+ .uids(singletonList(Uid.builder().id("u").build()))
+ .build()), KeyType.FIRST_PARTY, CacheResult.Layer.L1)));
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then
+ assertThat(counter("cache.l1.hit.first_party")).isEqualTo(1);
+ assertThat(counter("cache.miss.first_party")).isZero();
+ }
+
+ @Test
+ public void callShouldIncrementCacheMissOnCacheMiss() {
+ // given
+ final IdentityCache cache = mock(IdentityCache.class);
+ when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss()));
+ givenResponse(EIDS_BODY);
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then
+ assertThat(counter("cache.miss.first_party")).isEqualTo(1);
+ assertThat(counter("cache.l1.hit.first_party")).isZero();
+ }
+
+ @Test
+ public void callShouldMarkInProgressBeforeFetchingOnCacheMiss() {
+ // given
+ final IdentityCache cache = mock(IdentityCache.class);
+ when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss()));
+ givenResponse(EIDS_BODY);
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then — marker written so concurrent requests don't fire a duplicate, then the call is made
+ verify(cache).putInProgress(any());
+ verify(httpClient).get(any(), any(), anyLong());
+ }
+
+ @Test
+ public void callShouldReturnNoActionAndSkipHttpOnInProgressCacheResult() {
+ // given
+ final IdentityCache cache = mock(IdentityCache.class);
+ when(cache.get(any())).thenReturn(Future.succeededFuture(
+ CacheResult.inProgress(KeyType.FIRST_PARTY, CacheResult.Layer.L1)));
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ final InvocationResult result =
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then — a resolution is already in flight: do not fire a duplicate, do not enrich
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ verifyNoInteractions(httpClient);
+ verify(cache, never()).putInProgress(any());
+ }
+
+ @Test
+ public void callShouldIncrementCacheInProgressOnInProgressResult() {
+ // given
+ final IdentityCache cache = mock(IdentityCache.class);
+ when(cache.get(any())).thenReturn(Future.succeededFuture(
+ CacheResult.inProgress(KeyType.FIRST_PARTY, CacheResult.Layer.L1)));
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(
+ BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build());
+
+ // when
+ target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result();
+
+ // then
+ assertThat(counter("cache.l1.in_progress.first_party")).isEqualTo(1);
+ }
+
+ private void givenResponse(String body) {
+ when(httpClient.get(any(), any(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, null, body)));
+ }
+
+ private void givenCapturedResponse() {
+ when(httpClient.get(urlCaptor.capture(), headersCaptor.capture(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, null, EMPTY_BODY)));
+ }
+
+ private static BidRequest applyUpdate(InvocationResult result,
+ AuctionRequestPayload payload) {
+ final PayloadUpdate update = result.payloadUpdate();
+ return update.apply(payload).bidRequest();
+ }
+
+ private static String extractParam(String url, String key) {
+ final int start = url.indexOf("&" + key + "=") + key.length() + 2;
+ final int end = url.indexOf('&', start);
+ final String value = url.substring(start, end < 0 ? url.length() : end);
+ return URLDecoder.decode(value, StandardCharsets.UTF_8);
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgentTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgentTest.java
new file mode 100644
index 00000000000..d9db6af098f
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgentTest.java
@@ -0,0 +1,39 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1.core;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class DeviceUserAgentTest {
+
+ @Test
+ public void shouldReturnEmptyForBlankUa() {
+ assertThat(DeviceUserAgent.normalize(null)).isEmpty();
+ assertThat(DeviceUserAgent.normalize(" ")).isEmpty();
+ }
+
+ @Test
+ public void shouldReturnEmptyForUnrecognizedUa() {
+ assertThat(DeviceUserAgent.normalize("UA")).isEmpty();
+ }
+
+ @Test
+ public void shouldNormalizeIphoneSafari() {
+ final String ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
+ + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
+
+ assertThat(DeviceUserAgent.normalize(ua))
+ .contains("iOS17")
+ .contains("iPhone")
+ .doesNotContain(" ");
+ }
+
+ @Test
+ public void shouldBeDeterministic() {
+ final String ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
+
+ assertThat(DeviceUserAgent.normalize(ua)).isEqualTo(DeviceUserAgent.normalize(ua));
+ assertThat(DeviceUserAgent.normalize(ua)).contains("Chrome120");
+ }
+}
diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractorTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractorTest.java
new file mode 100644
index 00000000000..de2e2e88749
--- /dev/null
+++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractorTest.java
@@ -0,0 +1,146 @@
+package org.prebid.server.hooks.modules.intentiq.identity.v1.core;
+
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import org.junit.jupiter.api.Test;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheKey;
+import org.prebid.server.hooks.modules.intentiq.identity.cache.KeyType;
+
+import java.util.List;
+
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class FirstPartyKeyExtractorTest {
+
+ private final FirstPartyKeyExtractor target = new FirstPartyKeyExtractor(10);
+
+ @Test
+ public void shouldReturnEmptyWhenNoIdentifiersPresent() {
+ assertThat(target.candidateKeys(BidRequest.builder().build())).isEmpty();
+ }
+
+ @Test
+ public void shouldOrderKeysByPriorityAndTagTypes() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder()
+ .user(User.builder().eids(List.of(
+ eid("uidapi.com", "uid2-1"),
+ eid("pubcid.org", "pub-1"),
+ eid("intentiq.com", "iiq-1")))
+ .build())
+ .device(Device.builder().ifa("ifa-1").ua("UA").ip("1.2.3.4").build())
+ .build();
+
+ // when
+ final List keys = target.candidateKeys(bidRequest);
+
+ // then — iiq, pubcid, maid, other eid, device composite (unrecognized UA contributes nothing)
+ assertThat(keys).containsExactly(
+ new CacheKey("iiq:iiq-1", KeyType.THIRD_PARTY),
+ new CacheKey("pubcid:pub-1", KeyType.FIRST_PARTY),
+ new CacheKey("maid:ifa-1", KeyType.FIRST_PARTY),
+ new CacheKey("uidapi.com:uid2-1", KeyType.FIRST_PARTY),
+ new CacheKey("dev:ifa-1_1.2.3.4", KeyType.DEVICE));
+ }
+
+ @Test
+ public void shouldNormalizeUserAgentInDeviceCompositeRatherThanUseRawString() {
+ // given — a real iPhone Safari UA
+ final String rawUa = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
+ + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
+ final BidRequest bidRequest = BidRequest.builder()
+ .device(Device.builder().ua(rawUa).ip("1.2.3.4").build())
+ .build();
+
+ // when
+ final CacheKey deviceKey = target.candidateKeys(bidRequest).stream()
+ .filter(key -> key.type() == KeyType.DEVICE)
+ .findFirst()
+ .orElseThrow();
+
+ // then — normalized OS/browser/device tokens, never the raw UA string
+ assertThat(deviceKey.key())
+ .startsWith("dev:")
+ .endsWith("_1.2.3.4")
+ .contains("iOS")
+ .doesNotContain(rawUa)
+ .doesNotContain(" ");
+ }
+
+ @Test
+ public void shouldTreatSharedidOrgAsPubcid() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder()
+ .user(User.builder().eids(singletonList(eid("sharedid.org", "s-1"))).build())
+ .build();
+
+ // when / then
+ assertThat(target.candidateKeys(bidRequest))
+ .containsExactly(new CacheKey("pubcid:s-1", KeyType.FIRST_PARTY));
+ }
+
+ @Test
+ public void shouldSkipMaidKeyWhenLimitAdTracking() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder()
+ .device(Device.builder().ifa("ifa-1").lmt(1).build())
+ .build();
+
+ // when / then — only the device composite remains, no maid: key
+ assertThat(target.candidateKeys(bidRequest))
+ .extracting(CacheKey::key)
+ .doesNotContain("maid:ifa-1");
+ }
+
+ @Test
+ public void shouldUppercaseMaidForCtv() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder()
+ .device(Device.builder().ifa("rida-abc").devicetype(3).build())
+ .build();
+
+ // when / then
+ assertThat(target.candidateKeys(bidRequest))
+ .extracting(CacheKey::key)
+ .contains("maid:RIDA-ABC");
+ }
+
+ @Test
+ public void shouldDeduplicateRepeatedKeys() {
+ // given — same pubcid id twice
+ final BidRequest bidRequest = BidRequest.builder()
+ .user(User.builder().eids(List.of(eid("pubcid.org", "dup"), eid("pubcid.org", "dup"))).build())
+ .build();
+
+ // when / then
+ assertThat(target.candidateKeys(bidRequest))
+ .containsExactly(new CacheKey("pubcid:dup", KeyType.FIRST_PARTY));
+ }
+
+ @Test
+ public void shouldCapNumberOfKeys() {
+ // given — extractor capped at 2
+ final FirstPartyKeyExtractor capped = new FirstPartyKeyExtractor(2);
+ final BidRequest bidRequest = BidRequest.builder()
+ .user(User.builder().eids(List.of(
+ eid("intentiq.com", "iiq-1"),
+ eid("pubcid.org", "pub-1"),
+ eid("uidapi.com", "uid2-1")))
+ .build())
+ .build();
+
+ // when / then — only the two highest-priority keys survive
+ assertThat(capped.candidateKeys(bidRequest))
+ .containsExactly(
+ new CacheKey("iiq:iiq-1", KeyType.THIRD_PARTY),
+ new CacheKey("pubcid:pub-1", KeyType.FIRST_PARTY));
+ }
+
+ private static Eid eid(String source, String id) {
+ return Eid.builder().source(source).uids(singletonList(Uid.builder().id(id).build())).build();
+ }
+}
diff --git a/extra/modules/pom.xml b/extra/modules/pom.xml
index fe2b772d668..d5b8dcbc001 100644
--- a/extra/modules/pom.xml
+++ b/extra/modules/pom.xml
@@ -28,6 +28,7 @@
wurfl-devicedetection
live-intent-omni-channel-identity
pb-rule-engine
+ intentiq-identity
diff --git a/sample/configs/prebid-config-with-intentiq.yaml b/sample/configs/prebid-config-with-intentiq.yaml
new file mode 100644
index 00000000000..f35a0f39e53
--- /dev/null
+++ b/sample/configs/prebid-config-with-intentiq.yaml
@@ -0,0 +1,106 @@
+status-response: "ok"
+adapters:
+ generic:
+ enabled: true
+ endpoint: http://localhost:9099/bidder
+ appnexus:
+ enabled: true
+ ix:
+ enabled: true
+ openx:
+ enabled: true
+ pubmatic:
+ enabled: true
+ rubicon:
+ enabled: true
+metrics:
+ prefix: prebid
+ # JVM/system health (free memory, GC) is collected at the deployment level (JMX/process exporter),
+ # not by this module — keeps the shippable module free of server-level concerns.
+ prometheus:
+ enabled: true
+ port: 9090
+ namespace: prebid
+ custom-labels-enabled: false
+cache:
+ scheme: http
+ host: localhost
+ path: /cache
+ query: uuid=
+settings:
+ enforce-valid-account: false
+ generate-storedrequest-bidrequest-id: true
+ filesystem:
+ settings-filename: sample/configs/sample-app-settings.yaml
+ stored-requests-dir: sample
+ stored-imps-dir: sample
+ stored-responses-dir: sample/stored
+ categories-dir:
+gdpr:
+ default-value: 1
+ vendorlist:
+ v2:
+ cache-dir: /var/tmp/vendor2
+ v3:
+ cache-dir: /var/tmp/vendor3
+admin-endpoints:
+ logging-changelevel:
+ enabled: true
+ path: /logging/changelevel
+ on-application-port: true
+ protected: false
+hooks:
+ intentiq-identity:
+ enabled: true
+ host-execution-plan: >
+ {
+ "endpoints": {
+ "/openrtb2/auction": {
+ "stages": {
+ "processed-auction-request": {
+ "groups": [
+ {
+ "timeout": 6000,
+ "hook-sequence": [
+ {
+ "module-code": "intentiq-identity",
+ "hook-impl-code": "intentiq-identity-processed-auction-request-hook"
+ }
+ ]
+ }
+ ]
+ },
+ "auction-response": {
+ "groups": [
+ {
+ "timeout": 6000,
+ "hook-sequence": [
+ {
+ "module-code": "intentiq-identity",
+ "hook-impl-code": "intentiq-identity-auction-response-hook"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ modules:
+ intentiq-identity:
+ api-endpoint: http://127.0.0.1:9098/profiles_engine/ProfilesEngineServlet
+ reports-endpoint: http://127.0.0.1:9098/profiles_engine/ProfilesEngineServlet
+ partner-id: "383342646"
+ timeout: 6000
+ cache-max-size: 100000
+ cache:
+ enabled: true
+ ttlseconds: 43200
+ # dev: keep the in-progress marker short so a timed-out VR retries soon instead of
+ # poisoning the device key for 30 min (default 1800s) when the VPN is slow/flapping.
+ in-progress-ttl-seconds: 30
+ redis:
+ host: 127.0.0.1
+ port: 6379
+ password: