diff --git a/sample/configs/prebid-config-with-optable-ca.yaml b/sample/configs/prebid-config-with-optable-ca.yaml new file mode 100644 index 00000000000..9af83a04d31 --- /dev/null +++ b/sample/configs/prebid-config-with-optable-ca.yaml @@ -0,0 +1,67 @@ +status-response: "ok" +adapters: + appnexus: + enabled: true + criteo: + enabled: true + openx: + enabled: true + pubmatic: + enabled: true + magnite: + enabled: true + improvedigital: + enabled: true + colossus: + enabled: true + triplelift: + enabled: true + adf: + enabled: true + yieldmo: + enabled: true + grid: + enabled: true + unruly: + enabled: true + epsilon: + enabled: true + thirtythreeacross: + enabled: true +metrics: + prefix: prebid +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-optable.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 + live: + startup-cache-dir: /var/tmp/vendor3 +admin-endpoints: + logging-changelevel: + enabled: true + path: /logging/changelevel + on-application-port: true + protected: false +hooks: + optable-targeting: + enabled: true + modules: + optable-targeting: + api-endpoint: https://ca.edge.optable.co/v2/targeting?t={TENANT}&o={ORIGIN} diff --git a/sample/configs/prebid-config-with-optable-legacy-plan.yaml b/sample/configs/prebid-config-with-optable-legacy-plan.yaml new file mode 100644 index 00000000000..58a73d6db29 --- /dev/null +++ b/sample/configs/prebid-config-with-optable-legacy-plan.yaml @@ -0,0 +1,67 @@ +status-response: "ok" +adapters: + appnexus: + enabled: true + criteo: + enabled: true + openx: + enabled: true + pubmatic: + enabled: true + magnite: + enabled: true + improvedigital: + enabled: true + colossus: + enabled: true + triplelift: + enabled: true + adf: + enabled: true + yieldmo: + enabled: true + grid: + enabled: true + unruly: + enabled: true + epsilon: + enabled: true + thirtythreeacross: + enabled: true +metrics: + prefix: prebid +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-optable-legacy-plan.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 + live: + startup-cache-dir: /var/tmp/vendor3 +admin-endpoints: + logging-changelevel: + enabled: true + path: /logging/changelevel + on-application-port: true + protected: false +hooks: + optable-targeting: + enabled: true + modules: + optable-targeting: + api-endpoint: https://na.edge.optable.co/v2/targeting?t={TENANT}&o={ORIGIN} diff --git a/sample/configs/sample-app-settings-optable-legacy-plan.yaml b/sample/configs/sample-app-settings-optable-legacy-plan.yaml new file mode 100644 index 00000000000..b009cce4038 --- /dev/null +++ b/sample/configs/sample-app-settings-optable-legacy-plan.yaml @@ -0,0 +1,73 @@ +accounts: + - id: 1 + status: active + auction: + price-granularity: low + privacy: + ccpa: + enabled: true + gdpr: + enabled: true + cookie-sync: + default-limit: 8 + max-limit: 15 + coop-sync: + default: true + analytics: + allow-client-details: true + hooks: + modules: + optable-targeting: + api-key: key + tenant: prebidtest + origin: js-sdk + enrichment-percentage: 100 + bidder-enrichment-percentages: + appnexus: 75 + rubicon: 75 + pubmatic: 0 + improvedigital: 50 + enrich-web: true + enrich-app: true + id-prefix-order: "e,v,c" + hid-prefixes: "a,c,i6" + ppid-mapping: { "pubcid.org": "c" } + adserver-targeting: true + cache: + enabled: false + ttlseconds: 86400 + execution-plan: + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "processed-auction-request": { + "groups": [ + { + "timeout": 500, + "hook-sequence": [ + { + "module-code": "optable-targeting", + "hook-impl-code": "optable-targeting-processed-auction-request-hook" + } + ] + } + ] + }, + "auction-response": { + "groups": [ + { + "timeout": 10, + "hook-sequence": [ + { + "module-code": "optable-targeting", + "hook-impl-code": "optable-targeting-auction-response-hook" + } + ] + } + ] + } + } + } + } + } diff --git a/sample/optable-smoke-test.sh b/sample/optable-smoke-test.sh new file mode 100755 index 00000000000..796b5a53f13 --- /dev/null +++ b/sample/optable-smoke-test.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# +# Smoke test for the optable-targeting module against the live Optable edge. +# +# Runs three happy-path scenarios, each starting its own Prebid Server: +# 1. na.edge.optable.co - bidders enriched with eids AND id5_signature returned in passthrough +# 2. ca.edge.optable.co - bidders enriched with eids AND adserver targeting keywords on the +# bids, but no id5_signature (that tenant has no id5 there) +# 3. legacy execution plan (processed-auction-request stage) - bidders still enriched +# +# The two edges hold different data for the prebidtest tenant, which is why the expectations +# differ rather than being uniform: na resolves an id5 signature but returns an empty audience +# (so no keywords), ca returns three audience segments but no id5. Between them the two +# scenarios cover both halves of what the module renders. +# +# Scenarios 1 and 2 use the current split plan (raw-auction-request + bidder-request), which +# samples per bidder, so they also assert that pubmatic - configured at 0% enrichment in +# sample-app-settings-optable.yaml - is never enriched. +# +# Usage: ./sample/optable-smoke-test.sh build the bundle, then run +# ./sample/optable-smoke-test.sh --no-build reuse the existing bundle +# +# Run from the repository root. Requires JDK 25 and network access to the Optable edge. + +set -uo pipefail + +BUNDLE=extra/bundle/target/prebid-server-bundle.jar +REQUEST=sample/requests/optable-app-smoke.json +PORT=8090 +ADMIN_PORT=8061 +# The Optable edge derives its privacy jurisdiction from the forwarded device IP. +# device.ip in the request is deliberately non-EU: an EU address makes the edge +# return refs without a signature, which would fail scenario 1 for the wrong reason. + +build_bundle() { + echo "=== building $BUNDLE" + # maven-jar-plugin leaves an existing archive alone when it considers it up to date, so + # `mvn package` logs "Replacing main artifact" while a stale bundle survives source + # changes. Removing it first forces a real repackage without paying for a full clean. + rm -f "$BUNDLE" + if ! mvn -q package -Dmaven.test.skip=true -Ddocker.skip=true --file extra/pom.xml; then + echo "FAIL: build failed. The bundle needs JDK 25 - check java -version." + exit 1 + fi +} + +case ${1-} in + --no-build) + if [ ! -f "$BUNDLE" ]; then + echo "FAIL: --no-build given but $BUNDLE does not exist" + exit 1 + fi + echo "=== using existing $BUNDLE" + ;; + "") + build_bundle + ;; + *) + echo "usage: $0 [--no-build]" + exit 1 + ;; +esac + +server_pid="" +cleanup() { [ -n "$server_pid" ] && kill "$server_pid" 2>/dev/null; } +trap cleanup EXIT + +start_server() { + local config=$1 log=$2 + java -jar "$BUNDLE" \ + --spring.config.additional-location="$config" \ + --server.http.port=$PORT --admin.port=$ADMIN_PORT > "$log" 2>&1 & + server_pid=$! + + for _ in $(seq 1 60); do + if curl -sf -o /dev/null "http://localhost:$PORT/status"; then return 0; fi + sleep 2 + done + echo "FAIL: server did not come up, see $log" + return 1 +} + +stop_server() { + [ -z "$server_pid" ] && return 0 + kill "$server_pid" 2>/dev/null + wait "$server_pid" 2>/dev/null + server_pid="" + # A scenario whose server is still holding the port would silently be answered by + # the previous scenario's server, which passes for entirely the wrong reason. + for _ in $(seq 1 30); do + curl -sf -o /dev/null "http://localhost:$PORT/status" || return 0 + sleep 1 + done + echo "FAIL: port $PORT still in use after shutdown" + return 1 +} + +auction() { + curl -s -X POST "http://localhost:$PORT/openrtb2/auction" \ + -H "Content-Type: application/json" --data @"$REQUEST" +} + +# Writes an auction response that shows enrichment into $1. The live Optable edge +# intermittently drops a call issued back-to-back with the previous one, which enriches +# nobody; that is a transport hiccup rather than a module failure, so retry a few times +# before letting the assertions call it a real miss. +auction_with_enrichment() { + local out=$1 + for _ in 1 2 3 4 5; do + auction > "$out" + if grep -q '"eids"' "$out"; then return 0; fi + sleep 2 + done + return 0 +} + +# $1 = scenario name, $2 = config, $3 = expect_id5 (yes|no), +# $4 = per-bidder sampling (yes|no), $5 = expect adserver keywords (yes|no) +run_scenario() { + local name=$1 config=$2 expect_id5=$3 per_bidder=$4 expect_keywords=$5 + local log response status + log=$(mktemp) + response=$(mktemp) + + echo "=== $name ($config)" + start_server "$config" "$log" || return 1 + + # The first auction after startup pays for lazy init (TLS handshake, DNS, class loading) + # and routinely exceeds the hook timeout, so it is a warm-up rather than the assertion. + auction > /dev/null + sleep 2 + auction_with_enrichment "$response" + + stop_server || { rm -f "$log" "$response"; return 1; } + + EXPECT_ID5="$expect_id5" PER_BIDDER="$per_bidder" EXPECT_KEYWORDS="$expect_keywords" \ + python3 - "$response" <<'PY' +import json, os, sys + +response = json.load(open(sys.argv[1])) +expect_id5 = os.environ["EXPECT_ID5"] == "yes" +per_bidder = os.environ["PER_BIDDER"] == "yes" +expect_keywords = os.environ["EXPECT_KEYWORDS"] == "yes" +ext = response.get("ext", {}) +failures = [] + +# Enrichment: the bidder request carries the eids Optable resolved. +http_calls = ext.get("debug", {}).get("httpcalls", {}) +enriched = sorted( + bidder for bidder, calls in http_calls.items() + if any("eids" in (call.get("requestbody") or "") for call in calls) +) +if enriched: + print(" enriched bidders: " + ", ".join(enriched)) +else: + failures.append("no bidder request contained eids (called: %s)" % sorted(http_calls)) + +# pubmatic is pinned to 0% in sample-app-settings-optable.yaml. Only the split plan +# samples per bidder; the legacy plan enriches the whole request, so it is exempt. +if per_bidder: + if "pubmatic" not in http_calls: + failures.append("pubmatic was not called, cannot verify it is left unenriched") + elif "pubmatic" in enriched: + failures.append("pubmatic is configured at 0% enrichment but was enriched") + else: + print(" pubmatic (0% enrichment): not enriched, as expected") + +# adserver-targeting is on for this account, so the audience Optable returns must surface +# as ad server keywords on the bids. Anything that is not an hb_* key was put there by the +# module (the keyspace itself is tenant-owned, so it is not hardcoded here). +keywords = {} +for seatbid in response.get("seatbid") or []: + for bid in seatbid.get("bid") or []: + targeting = (bid.get("ext") or {}).get("prebid", {}).get("targeting") or {} + keywords.update({k: v for k, v in targeting.items() if not k.startswith("hb_")}) + +if expect_keywords and not keywords: + failures.append("expected adserver targeting keywords on a bid, got none") +elif not expect_keywords and keywords: + failures.append("expected no targeting keywords, got %s" % json.dumps(keywords)) +else: + print(" adserver targeting keywords: %s" + % (json.dumps(keywords) if keywords else "none (tenant has no segments on this edge)")) + +signature = ( + ext.get("prebid", {}).get("passthrough", {}) or {} +).get("optable", {}).get("id5_signature") + +if expect_id5 and not signature: + failures.append("expected id5_signature in ext.prebid.passthrough, got none") +elif not expect_id5 and signature: + failures.append("expected no id5_signature, got %s..." % signature[:32]) +else: + print(" id5_signature: %s" % (signature[:40] + "..." if signature else "absent (as expected)")) + +if failures: + for failure in failures: + print(" FAIL: " + failure) + sys.exit(1) +print(" PASS") +PY + status=$? + rm -f "$log" "$response" + return $status +} + +overall=0 +run_scenario "na - enrichment + id5" \ + sample/configs/prebid-config-with-optable.yaml yes yes no || overall=1 +run_scenario "ca - enrichment + adserver keywords, no id5" \ + sample/configs/prebid-config-with-optable-ca.yaml no yes yes || overall=1 +run_scenario "legacy execution plan - enrichment" \ + sample/configs/prebid-config-with-optable-legacy-plan.yaml yes no no || overall=1 + +echo +[ $overall -eq 0 ] && echo "ALL SMOKE TESTS PASSED" || echo "SMOKE TESTS FAILED" +exit $overall diff --git a/sample/requests/optable-app-smoke.json b/sample/requests/optable-app-smoke.json new file mode 100644 index 00000000000..d3813a7337e --- /dev/null +++ b/sample/requests/optable-app-smoke.json @@ -0,0 +1,66 @@ +{ + "imp": [ + { + "ext": { + "prebid": { + "bidder": { + "triplelift": { "inventoryCode": "123" }, + "pubmatic": { "publisherId": "156209", "adSlot": "/1234567/test" }, + "yieldmo": { "placementId": "12314235" }, + "openx": { "delDomain": "domain.openx.net", "unit": "12324324", "customParams": { "sens": ["alc"] } }, + "grid": { "uid": 123 }, + "unruly": { "siteId": 567890 }, + "conversant": { "tag_id": "some_id", "site_id": "345678" }, + "33across": { "siteId": "some_id", "productId": "ddff" }, + "adform": { "mname": "some_name", "mid": 123456789, "adxDomain": "adx2.adform.net" }, + "improvedigital": { "publisherId": 1234, "placementId": 123456789 }, + "colossus": { "groupId": "123" } + }, + "adunitcode": "AdUnit_Name_1" + } + }, + "id": "imp_id", + "banner": { "topframe": 1, "format": [ { "w": 320, "h": 50 } ] }, + "bidfloor": 1.0, + "bidfloorcur": "USD" + } + ], + "cur": [ "USD" ], + "at": 1, + "device": { + "w": 591, "h": 771, "dnt": 0, + "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", + "language": "en", "js": 1, "ip": "8.8.8.8", + "ifa": "bbd4eabb-7258-4ba8-9988-3b876441bdff", + "os": "ios", + "sua": { + "source": 2, + "platform": { "brand": "macOS", "version": [ "14", "0", "0" ] }, + "browsers": [ + { "brand": "Chromium", "version": [ "120", "0", "0", "0" ] }, + { "brand": "Not-A.Brand", "version": [ "24", "0", "0", "0" ] }, + { "brand": "Google Chrome", "version": [ "120", "0", "0", "0" ] } + ], + "mobile": 0, "model": "", "architecture": "arm" + } + }, + "app": { "bundle": "295646461", "ver": "1.0", "publisher": { "id": 1 } }, + "user": { + "data": [], + "ext": { + "optable": { "email": "5837d278eabede28e37b5766399ed0d1a4cdc36acee8d35710a255032f45beda" }, + "consent": "", + "ConsentedProvidersSettings": { "consented_providers": "2~70.1516.89.320.2778.2322.510.1516.2343~dv." } + } + }, + "regs": { "gpp": "DBABzw~1YNY~BVQqAAAAAgA", "gpp_sid": [ 6, 5, 7 ], "ext": { "us_privacy": "1YNY" } }, + "ext": { + "prebid": { + "targeting": { "includewinners": true, "includebidderkeys": false }, + "analytics": { "options": { "enableclientdetails": true } } + } + }, + "tmax": 1000, + "id": "id", + "test": 1 +}