From 7876272e11f04e1472a61fa70cce3957915b48bf Mon Sep 17 00:00:00 2001 From: Ben Gojanski Date: Wed, 1 Jul 2026 15:43:35 +0300 Subject: [PATCH] IntentIQ Identity module: server-side identity resolution --- extra/bundle/pom.xml | 5 + extra/modules/intentiq-identity/README.md | 220 +++++ extra/modules/intentiq-identity/pom.xml | 32 + .../intentiq/identity/cache/CacheKey.java | 9 + .../intentiq/identity/cache/CacheResult.java | 51 ++ .../identity/cache/CacheTtlPolicy.java | 41 + .../identity/cache/IdentityCache.java | 269 +++++++ .../identity/cache/IdentityStore.java | 16 + .../intentiq/identity/cache/KeyType.java | 14 + .../identity/cache/RedisIdentityStore.java | 55 ++ .../identity/cache/RedisStatsReporter.java | 53 ++ .../config/IntentiqIdentityConfig.java | 146 ++++ .../metric/IntentiqIdentityMetrics.java | 183 +++++ .../metric/NoopIntentiqIdentityMetrics.java | 26 + .../model/IntentiqIdentityModuleContext.java | 13 + .../model/config/CacheProperties.java | 43 + .../config/IntentiqIdentityProperties.java | 32 + .../model/config/RedisProperties.java | 13 + .../IntentiqIdentityAuctionResponseHook.java | 250 ++++++ .../identity/v1/IntentiqIdentityModule.java | 17 + ...iqIdentityProcessedAuctionRequestHook.java | 478 +++++++++++ .../identity/v1/core/ConfigResolver.java | 50 ++ .../identity/v1/core/DeviceUserAgent.java | 58 ++ .../v1/core/FirstPartyKeyExtractor.java | 130 +++ .../intentiq/identity/v1/core/IiqParam.java | 45 ++ .../identity/cache/IdentityCacheTest.java | 447 +++++++++++ .../cache/RedisIdentityStoreTest.java | 128 +++ .../cache/RedisStatsReporterTest.java | 133 ++++ .../config/IntentiqIdentityConfigTest.java | 175 ++++ .../metric/IntentiqIdentityMetricsTest.java | 165 ++++ ...tentiqIdentityAuctionResponseHookTest.java | 233 ++++++ .../v1/IntentiqIdentityModuleTest.java | 55 ++ ...entityProcessedAuctionRequestHookTest.java | 750 ++++++++++++++++++ .../identity/v1/core/DeviceUserAgentTest.java | 39 + .../v1/core/FirstPartyKeyExtractorTest.java | 146 ++++ extra/modules/pom.xml | 1 + .../configs/prebid-config-with-intentiq.yaml | 106 +++ 37 files changed, 4627 insertions(+) create mode 100644 extra/modules/intentiq-identity/README.md create mode 100644 extra/modules/intentiq-identity/pom.xml create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheKey.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheResult.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheTtlPolicy.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCache.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityStore.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/KeyType.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStore.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporter.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfig.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetrics.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/NoopIntentiqIdentityMetrics.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/IntentiqIdentityModuleContext.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/CacheProperties.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/IntentiqIdentityProperties.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/RedisProperties.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHook.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModule.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHook.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/ConfigResolver.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgent.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractor.java create mode 100644 extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/IiqParam.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCacheTest.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStoreTest.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporterTest.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfigTest.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetricsTest.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHookTest.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModuleTest.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHookTest.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgentTest.java create mode 100644 extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractorTest.java create mode 100644 sample/configs/prebid-config-with-intentiq.yaml diff --git a/extra/bundle/pom.xml b/extra/bundle/pom.xml index d49f160f9af..3531c07d587 100644 --- a/extra/bundle/pom.xml +++ b/extra/bundle/pom.xml @@ -75,6 +75,11 @@ pb-rule-engine ${project.version} + + org.prebid.server.hooks.modules + intentiq-identity + ${project.version} + diff --git a/extra/modules/intentiq-identity/README.md b/extra/modules/intentiq-identity/README.md new file mode 100644 index 00000000000..fc7d50e5d4a --- /dev/null +++ b/extra/modules/intentiq-identity/README.md @@ -0,0 +1,220 @@ +## Overview + +The IntentIQ Identity module enriches an incoming OpenRTB request by adding resolved IDs to +`user.eids`. At the `processed-auction-request` stage it calls the IntentIQ Bid Enhancement S2S API +(`ProfilesEngineServlet`) and merges the eids from the response into `user.eids` before the request +is sent to bidders. Please contact your IntentIQ account manager to get a partner token. + +See the [S2S integration docs](https://s2s.documents.intentiq.com/) for the full API contract. + +## Setup + +### Execution Plan + +This module runs at two stages: `processed-auction-request` (enrich `user.eids`) and, optionally, +`auction-response` (report winning bids as impressions to `reports-endpoint`). Enable the module and +add the hook(s) to the execution plan: + +```yaml +hooks: + intentiq-identity: + enabled: true + host-execution-plan: > + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "processed-auction-request": { + "groups": [ + { "timeout": 100, "hook-sequence": [ + { "module-code": "intentiq-identity", + "hook-impl-code": "intentiq-identity-processed-auction-request-hook" } ] } ] + }, + "auction-response": { + "groups": [ + { "timeout": 100, "hook-sequence": [ + { "module-code": "intentiq-identity", + "hook-impl-code": "intentiq-identity-auction-response-hook" } ] } ] + } + } + } + } + } + modules: + intentiq-identity: + api-endpoint: https://be-api-s2s.intentiq.com/profiles_engine/ProfilesEngineServlet + reports-endpoint: https://reports-s2s.intentiq.com/profiles_engine/ProfilesEngineServlet + partner-id: "1234567890" + timeout: 1000 + cache-max-size: 100000 + cache: + enabled: true + ttlseconds: 43200 + max-keys: 10 + ttl-ceiling-first-party-seconds: 86400 + ttl-ceiling-third-party-seconds: 43200 + ttl-ceiling-device-seconds: 3600 + negative-ttl-seconds: 120 + in-progress-ttl-seconds: 1800 + redis: + host: localhost + port: 6379 +``` + +Use the region-specific `api-endpoint`: US `be-api-s2s.intentiq.com`, EU `be-api-s2s-gdpr.intentiq.com`, +APAC `be-api-s2s-apac.intentiq.com`. When `api-endpoint` is empty the hook is a no-op. + +### Account-level config + +Host-level config (above) provides defaults. Account-specific values can be set under the account's +`hooks.modules.intentiq-identity` config and are merged over the host defaults per request — so +`partner-id`, `timeout`, and `cache.*` can be tuned per account. + +## Module Configuration Parameters + +| Param Name | Level | Required | Type | Default | Description | +|:------------------|:--------|:---------|:--------|:---------|:---------------------------------------------------------------------| +| api-endpoint | host | yes | string | none | Bid Enhancement `ProfilesEngineServlet` URL (region-specific) | +| reports-endpoint | host | no | string | none | Impression-reporting `ProfilesEngineServlet` URL; blank disables it | +| partner-id | account | yes | string | none | Partner token from IntentIQ, sent as the `dpi` query parameter | +| timeout | account | no | long | 1000 | HTTP timeout (ms) for the identity-resolution call | +| cache.enabled | account | no | boolean | false | Use the Caffeine + Redis cache (host must configure `redis.*`) | +| cache.ttlseconds | account | no | int | 43200 | Fallback TTL (seconds) when the response omits `cttl` (12h) | +| cache.max-keys | account | no | int | 10 | Max alias keys derived per request (caps eid-stuffed requests) | +| cache.ttl-ceiling-first-party-seconds | account | no | int | 86400 | Upper bound on TTL for first-party id keys (pubcid, MAID, other eids) | +| cache.ttl-ceiling-third-party-seconds | account | no | int | 43200 | Upper bound on TTL for third-party id keys (`intentiq.com`) | +| cache.ttl-ceiling-device-seconds | account | no | int | 3600 | Upper bound on TTL for the probabilistic device-composite key | +| cache.negative-ttl-seconds | account | no | int | 120 | TTL for the negative (unresolvable id) sentinel | +| cache.in-progress-ttl-seconds | account | no | int | 1800 | TTL for the IN_PROGRESS marker that dedups concurrent resolution calls (matches the backend's 30m window) | +| cache-max-size | host | no | long | 100000 | Max entries in the local (Caffeine) layer | +| metrics-enabled | host | no | boolean | true | Record the module's `custom.*` metrics; set `false` to opt out | +| redis.host | host | cond. | string | none | Redis host (required when caching) | +| redis.port | host | cond. | int | none | Redis port (required when caching) | +| redis.password | host | no | string | none | Redis password | + +The hook sends `at/mi/pt/dpn/srvrReq` constants plus `dpi` (= `partner-id`), and — when present on +the request — `ip`, `ipv6`, `uas`, `ref` (site domain / app name), `iiquid` (an existing +`intentiq.com` eid), and `pcid`+`idtype` from `device.ifa` (`idtype 4` for MAID/AAID, `idtype 8` for +CTV with the id upper-cased; skipped when `device.lmt = 1`). The response `data.eids` are merged into +`user.eids`; on any failure the hook takes no action and the auction proceeds unchanged. + +### Caching + +When caching is enabled, resolved eids are cached in two layers: **Caffeine** (L1, in-process) backed +by an `IdentityStore` (L2, shared) — **Redis** by default. L2 failures are non-fatal: the hook falls +through to a live API call. A partner can use a non-Redis backend by supplying their own +`IdentityStore` bean (the default Redis store is `@ConditionalOnMissingBean`). + +**Multi-key (alias) caching.** Every relevant first-party id on the request becomes a namespaced +alias key, ordered by priority: `iiq:` (`intentiq.com`), `pubcid:` (`pubcid.org` / +`sharedid.org`), `maid:` (`device.ifa`; upper-cased for CTV, skipped when `device.lmt = 1`), +`:` for any other eid (e.g. `uidapi.com`), and a probabilistic `dev:` composite +as last resort. Keys are de-duplicated and capped at `cache.max-keys`. On a lookup 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. +Differing resolutions are never merged (only the single winning entry propagates). + +**TTL.** The response `cttl` (or `cache.ttlseconds` when omitted) always wins, but is capped per id +class by `cache.ttl-ceiling-{first-party,third-party,device}-seconds` — the cache holds the volatile +resolved eids, so ceilings are upper bounds, not the IntentIQ backend's long mapping TTLs. + +**Negative caching.** When the API resolves no eids for a request, a negative sentinel is written +under all candidate keys so unresolvable ids do not re-hit the S2S API on every request; a negative +cache hit makes the hook take no action and skip the call. The suppression window honors the response +`cttl` when present (the backend signals how long to suppress for empty/invalid traffic, capped at the +first-party ceiling), falling back to `cache.negative-ttl-seconds` when `cttl` is absent. + +**In-progress dedup.** On a full miss, before the live call an `IN_PROGRESS` marker +(`cache.in-progress-ttl-seconds`) is written under all candidate keys. A concurrent request for the +same id reads the marker and skips firing a duplicate call (it proceeds without enrichment); the +in-flight call populates the cache for subsequent requests. The marker is overwritten by the resolved +(or negative) entry when the call completes, or expires if it never does. A resolved entry is always +preferred over the marker on read. + +### Impression reporting + +When `reports-endpoint` is set and the `auction-response` hook is in the execution plan, the module +reports each winning `seatbid[].bid[]` to the IntentIQ impression API — a fire-and-forget GET to +`?at=45&rtype=1&dpi=&rdata=`. The `rdata` +carries `bidderCode` (seat), `cpm`, `currency`, `placementId` (imp id), `biddingPlatformId=4`, +`vrref` (site domain / app), `ip`, `ua`, `prebidAuctionId`, and `abTestUuid`. The `abTestUuid` is the +one returned by the resolution response — the resolution hook stashes it in the module context and +this hook reads it (omitted on a cache hit, since no fresh IIQ response was produced). With +`reports-endpoint` blank the hook is a no-op. The bid response is never modified. + +### Metrics + +The hook framework already emits per-module `call` / `success.*` / `failure` / `timeout` / +`execution-error` / `duration` for free under +`modules.module.intentiq-identity.stage..hook..…` (`` is `procauction` for the +request hook, `auctionresponse` for the response hook). The granularity is driven by what each hook +returns (`update` when enriched, `noAction` otherwise, `failed` on error). + +In addition the module emits the custom counters below (implemented in `IntentiqIdentityMetrics`). +Recording is **on by default**; set `metrics-enabled: false` (host-level) to disable it entirely. +**Per-partner** metrics are suffixed with `_` (the `partner-id`, never an internal backend id), +following the per-partner Graphite naming convention so the same per-partner Grafana templating applies; the +suffix is omitted when no partner id is configured. The `cache.*` outcome counters are additionally +broken down **by layer** (`l1` = Caffeine in-process, `l2` = Redis) and by `` — `third_party` +(`intentiq.com`), `first_party` (pubcid / MAID / other eids), or `device` (the probabilistic UA+IP +composite); on a HIT/NEGATIVE/IN_PROGRESS the keytype is the key that matched, on a full miss the +request's highest-priority candidate. + +``` +modules.module.intentiq-identity.custom.cache.l1.hit._ # positive entry served from L1 (Caffeine) +modules.module.intentiq-identity.custom.cache.l2.hit._ # positive entry served from L2 (Redis) +modules.module.intentiq-identity.custom.cache.l1.negative.hit._ # negative sentinel from L1; counted as miss, no API call +modules.module.intentiq-identity.custom.cache.l2.negative.hit._ # negative sentinel from L2; counted as miss, no API call +modules.module.intentiq-identity.custom.cache.l1.in_progress._ # in-flight marker from L1; duplicate API call skipped +modules.module.intentiq-identity.custom.cache.l2.in_progress._ # in-flight marker from L2; duplicate API call skipped +modules.module.intentiq-identity.custom.cache.miss._ # full miss (neither L1 nor L2) -> API called +modules.module.intentiq-identity.custom.api.success_ # resolution API responded and parsed OK +modules.module.intentiq-identity.custom.api.error_ # resolution API failed/timed out/unparseable +modules.module.intentiq-identity.custom.api.latency_ # resolution API call duration (timer; every call) +modules.module.intentiq-identity.custom.flow.latency_ # whole-flow latency: enrich hook -> bid release (timer; per auction) +modules.module.intentiq-identity.custom.enriched_ # eids added to user.eids (a match) +modules.module.intentiq-identity.custom.eids.none_ # resolution produced no eids (pairs with enriched for match rate) +modules.module.intentiq-identity.custom.skip.no_endpoint_ # no api-endpoint configured; resolution skipped before any API call +modules.module.intentiq-identity.custom.tc._ # one counter per enumerated termination-cause id +modules.module.intentiq-identity.custom.impression.reported_ # winning bid reported to reports-endpoint (overall) +modules.module.intentiq-identity.custom.impression.error_ # impression report call failed +``` + +Shared L1 (Caffeine) / L2 (Redis) health is process-wide, so these are emitted **globally — without +the `_` suffix** (one series each): + +``` +modules.module.intentiq-identity.custom.l1.size # current L1 entry count (gauge; vs cache-max-size) +modules.module.intentiq-identity.custom.l1.eviction # cumulative L1 evictions (gauge) +modules.module.intentiq-identity.custom.l1.get.error # L1 read threw (≈never; treated as miss) +modules.module.intentiq-identity.custom.l1.put.error # L1 write threw (≈never) +modules.module.intentiq-identity.custom.l2.get.latency # L2 GET duration (timer; every probe) +modules.module.intentiq-identity.custom.l2.put.latency # L2 PUT duration (timer; every write) +modules.module.intentiq-identity.custom.l2.get.error # L2 GET failed -> fell through to live API (fail-open) +modules.module.intentiq-identity.custom.l2.put.error # L2 PUT failed (entry still in L1, not in shared store) +modules.module.intentiq-identity.custom.l2.size # Redis DBSIZE (gauge; polled ~30s; INSTANCE-WIDE) +modules.module.intentiq-identity.custom.l2.eviction # Redis INFO evicted_keys (gauge; polled ~30s; INSTANCE-WIDE) +``` + +JVM / system health (free memory, GC) is provided by **prebid-server core**, not this module — enable +`metrics.jmx.enabled: true` and it registers `jvm.memory.*` and `jvm.gc.*` into the same registry. + +> **Prometheus / scrape gotcha:** if these are scraped via the Prometheus `/metrics` endpoint, set +> `metrics.metricType: counter` — **not** the default `flushingCounter`, which resets after each +> report (correct for Graphite/InfluxDB push, wrong for scrape) and would make the counters read as +> near-zero on every scrape. The server logs a warning when Prometheus is enabled with +> `flushingCounter`. + +## Running the demo + +1. Build the bundle: `mvn clean package --file extra/pom.xml` +2. Set `api-endpoint` and `partner-id` in `sample/configs/prebid-config-with-intentiq.yaml`. +3. Run: + `java -jar target/prebid-server-bundle.jar --spring.config.additional-location=sample/configs/prebid-config-with-intentiq.yaml` +4. POST a request to `/openrtb2/auction` and observe `user.eids` enriched in `ext.debug.resolvedrequest`. + +## Maintainer contacts + +Any suggestions or questions can be directed to the IntentIQ team. Alternatively please open a new +[issue](https://github.com/prebid/prebid-server-java/issues/new) or +[pull request](https://github.com/prebid/prebid-server-java/pulls) in this repository. diff --git a/extra/modules/intentiq-identity/pom.xml b/extra/modules/intentiq-identity/pom.xml new file mode 100644 index 00000000000..2d1b5d6ede8 --- /dev/null +++ b/extra/modules/intentiq-identity/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + + org.prebid.server.hooks.modules + all-modules + 3.44.0-SNAPSHOT + + + intentiq-identity + + intentiq-identity + IntentIQ server-side identity resolution module + + + 1.6.1 + + + + + io.vertx + vertx-redis-client + + + com.github.ua-parser + uap-java + ${uap-java.version} + + + diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheKey.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheKey.java new file mode 100644 index 00000000000..b004f9347bf --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheKey.java @@ -0,0 +1,9 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +/** + * A single namespaced cache key derived from a first-party identifier on the bid request, together + * with its {@link KeyType} (used to pick the TTL ceiling). A request yields an ordered list of these; + * the resolved identity is aliased across all of them. + */ +public record CacheKey(String key, KeyType type) { +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheResult.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheResult.java new file mode 100644 index 00000000000..07e50ee7373 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheResult.java @@ -0,0 +1,51 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +import com.iab.openrtb.request.Eid; + +import java.util.List; + +/** + * Outcome of a multi-key cache lookup: + *
    + *
  • {@link State#HIT} — a positive entry was found; {@link #eids()} carries the resolved identity.
  • + *
  • {@link State#NEGATIVE} — a negative sentinel was found (the id is known-unresolvable); skip the + * upstream call and do not enrich.
  • + *
  • {@link State#IN_PROGRESS} — a resolution call for this id is already in flight; skip the + * upstream call (do not fire a duplicate) and do not enrich.
  • + *
  • {@link State#MISS} — nothing cached; fetch from the API.
  • + *
+ */ +public record CacheResult(State state, List eids, KeyType keyType, Layer layer) { + public enum State { + HIT, + NEGATIVE, + IN_PROGRESS, + MISS + } + + /** Which cache layer served the outcome: {@code L1} (in-process Caffeine) or {@code L2} (Redis). */ + public enum Layer { + L1, + L2 + } + + // keyType is the type of the candidate key that produced the outcome (HIT/NEGATIVE/IN_PROGRESS); + // both keyType and layer are null for MISS, where no key/layer matched. + private static final CacheResult MISS = new CacheResult(State.MISS, List.of(), null, null); + + public static CacheResult hit(List 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> 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> 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: