Skip to content

optable-targeting: Hid plus id5 Mobile In-app resolver params - #3

Open
softcoder594 wants to merge 12 commits into
optable-targeting-non-blocking-early-network-callfrom
optable-targeting-hid-plus-id5-mobile-inapp-resolver-params
Open

optable-targeting: Hid plus id5 Mobile In-app resolver params#3
softcoder594 wants to merge 12 commits into
optable-targeting-non-blocking-early-network-callfrom
optable-targeting-hid-plus-id5-mobile-inapp-resolver-params

Conversation

@softcoder594

@softcoder594 softcoder594 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

🔧 Type of changes

  • new bid adapter
  • bid adapter update
  • new feature
  • new analytics adapter
  • new module
  • module update
  • bugfix
  • documentation
  • configuration
  • dependency update
  • tech debt (test coverage, refactorings, etc.)

What's the context?

This update introduces hid (resolver hint) support together with the ID5 Mobile In-App resolver additions.

New features

  1. hid query params (new config entry) -  it’s an independent parameter from id, driven by its own config entry: hid-prefixes — a comma-separated set of id prefixes to emit as hid=, in the given order (e.g. "e,p,i6,a,g,c,c1"). Selection semantics: only the listed prefixes are sent as hid, unlike id-prefix-order which only orders.
  2. Bundle and ver params of Targeting API call - emit app bundle and version parameters to the Targeting API when they are present as part of the request (bidRequest.app).
  3. id5_signature input - consume id5_signature from client and propagate it as Targeting API query attribute (alongside email/phone/zip/vid).
  4. id5_signature output - PBS cannot persist the signature between requests, so it round-trips through the client: the client caches the value we return and replays it via ext.prebid.passthrough.optable.id5_signature on the next auction (step 3)

🧪 Test plan

  • Unit Tests
  • Manual Integration Tests

🏎 Quality check

  • Are your changes following our code style guidelines?
  • Are there any breaking changes in your code?
  • Does your test coverage exceed 90%?
  • Are there any erroneous console logs, debuggers or leftover code in your changes?

@softcoder594 softcoder594 changed the title Optable targeting hid plus id5 mobile inapp resolver params optable-targeting: Hid plus id5 Mobile In-app resolver params Jul 27, 2026

@eugenedorfman-optable eugenedorfman-optable left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review

Blocking issues below. Two of them (un-encoded query params, cache key) are also security-relevant, so flagging as request-changes rather than nits.

Most findings are left as inline comments. Two anchor on lines outside the diff, so they are here:

Blocking: new user-identifying params are excluded from the cache key

CachedAPIClient.java:55-63 keys the cache on tenant:origin:ips.getFirst():query.getIds(). This PR adds hid (including i6, which is explicitly filtered out of the ids string in buildIdsString), id5_signature, bundle and ver to the request, and none of them enter the key.

In exactly the post-ATT mobile in-app case this PR targets there is no IFA, so ipv6 and the signature are the only discriminators. Two devices behind the same first IP with the same (possibly empty) id= set will collide: the second gets the first user's cached TargetingResult, including ID5 EIDs and the refs signature. That signature is then round-tripped into the second user's client via ext.prebid.passthrough.optable.id5_signature and replayed on their subsequent auctions, so the contamination persists past the cache TTL.

CacheTest was only touched for constructor arity; nothing covers the key contents. Please add the new params to createCachingKey and a test that asserts two requests differing only in hid / id5_signature / bundle / ver do not share a cache entry.

Should fix: the response hook now blocks on the network future under a 10 ms budget

The sample plan gives the auction-response group timeout: 10 (sample/configs/sample-app-settings-optable.yaml:73). The hook now does moduleContext.getOptableTargetingCall().compose(...), so if the targeting call is still pending at response time (for example the bidder-request hook already recovered on its own timeout) the response hook waits on it and the group times out. Before this PR the response hook enriched synchronously from moduleContext.getTargeting() and could not be delayed by the network call.

Suggest either awaiting the future only when the id5 signature is actually needed, or short-circuiting when the future is not yet complete and falling back to the already-resolved targeting.

Minor

  • Id5Resolver.java:16-20: STR_OPTABLE_CO / STR_ID_5_SYNC_COM is not the naming convention used elsewhere in PBS; OPTABLE_INSERTER / ID5_SOURCE would read better.
  • QueryBuilder.java:61 uses String.format where the surrounding code uses .formatted().

private Future<InvocationResult<AuctionResponsePayload>> enrichedById5SignaturePayload(
ModuleContext moduleContext) {

return moduleContext.getOptableTargetingCall()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking: NPE when optableTargetingCall was never set.

moduleContext.getOptableTargetingCall() is dereferenced here (and at line 74) with no null guard, but the future is set in exactly one place, OptableRawAuctionRequestHook:98. Paths where it is null at response time:

  1. Deprecated plan (processed-auction-request hook instead of the raw hook): nothing ever stores the future, so even a fully successful targeting call NPEs here. That silently drops adserver-targeting keyword enrichment for every request, which is a regression - before this PR the hook enriched synchronously from moduleContext.getTargeting().
  2. Raw-hook plan, early returns: OptableRawAuctionRequestHook returns without setting the future when biddersToEnrich is empty (lines 83-85) or the properties are invalid (lines 66-74), and shouldSkipEnrichment stays false. The sample config ships pubmatic: 0, so a pubmatic-only request hits this on every auction. OptableBidderRequestHook:41-44 guards this case; this hook does not.
  3. A fresh ModuleContext (ModuleContext.of returns new ModuleContext() when the invocation context has none).

GroupExecutor catches the Throwable, so nothing crashes - the module just fails and all response enrichment is dropped, which is why this does not show up as a test failure. The test churn is the tell: pre-existing tests had to be handed futures (OptableTargetingAuctionResponseHookTest.java:66-68) to keep passing.

Please null-guard and fall back to the previous synchronous path, and add a test that runs the response hook with a ModuleContext that has no optableTargetingCall.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

final String hidParameters = Arrays.stream(hidPrefixesString.split(","))
.map(prefixToIdValue::get)
.filter(Objects::nonNull)
.map(it -> String.format("hid=%s:%s", it.getName(), it.getValue()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking: hid values are appended un-encoded. Compare with buildIdsString below, which percent-encodes id= values, and with id5_signature at line 140 which is also encoded.

These values are client-controlled (user.ext.optable.*, eids, device.ipv6). Two consequences:

  1. Corruption. An E.164 phone p:+15551234 reaches the edge as p: 15551234 because gin decodes + as a space. idtype.Parse then fails and the resolver silently continues past the hint, so the hid is dropped with no error anywhere.
  2. Query-param injection. A value containing & injects arbitrary params into the targeting call, and the v2 targeting handler exposes live control params (skip_resolvers, skip_matchers, environment, id5_signature). So a crafted id lets a caller disable or redirect resolver behaviour server-side.

URLEncoder.encode the name:value pair the same way buildIdsString does. Note the new tests assert the un-encoded output (shouldBuildHidWhenHidPrefixesMatchIds), so they need updating too - right now they lock the bug in.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

.ifPresent(app -> {
final String bundle = app.getBundle();
if (StringUtils.isNotEmpty(bundle)) {
sb.append("&bundle=").append(bundle);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking (same class of bug as hid): bundle and ver are appended un-encoded.

app.bundle and app.ver come straight from the bid request. A bundle such as com.example&skip_resolvers=id5 injects a control param into the targeting call. Percent-encode both.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

return StringUtils.EMPTY;
}

final String hidParameters = Arrays.stream(hidPrefixesString.split(","))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix: hid-prefixes tokens are not trimmed.

"e, p, i6".split(",") yields " p" and " i6", which never match an id name, so those prefixes are silently dropped and the operator gets no hid params and no error. The config is documented as "a comma-separated set", and a space after a comma is the natural way to write that.

Add .map(String::trim) and drop empty tokens. Worth logging (or failing config validation on) a prefix that matches no known id name, otherwise a typo is invisible. Tests for whitespace and empty tokens are missing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

}
final Map<String, Id> prefixToIdValue = ids.stream()
.collect(Collectors.toMap(Id::getName, it -> it, (a, b) -> b));
if (MapUtils.isEmpty(prefixToIdValue)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dead code: ids is already known non-empty at this point, so the collected map cannot be empty.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

Comment on lines +131 to +134
final String ver = app.getVer();
if (StringUtils.isNotEmpty(ver)) {
sb.append("&ver=").append(ver);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ver is only emitted when bundle is non-empty, but the server treats them as independent ResolverParams fields. An app that sets app.ver without app.bundle loses the version for no reason.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It's Ok

Comment on lines +29 to +56
return null;
}

final String ref = Optional.of(targetingResult)
.map(TargetingResult::getOrtb2)
.map(Ortb2::getUser)
.map(User::getEids)
.orElseGet(List::of)
.stream()
.filter(it -> STR_OPTABLE_CO.equals(it.getInserter()) && STR_ID_5_SYNC_COM.equals(it.getSource()))
.map(Eid::getUids)
.flatMap(Collection::stream)
.map(Uid::getExt)
.filter(Objects::nonNull)
.map(it -> it.get(STR_OPTABLE))
.filter(Objects::nonNull)
.map(it -> it.get(STR_REF))
.filter(Objects::nonNull)
.map(JsonNode::asText)
.findFirst()
.orElse(null);

if (ref == null) {
return null;
}

return Optional.ofNullable(targetingResult.getRefs())
.map(refs -> refs.get(ref))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the first matching eid's ref is considered. If refs has no entry under that particular ref, a valid signature reachable through a second matching eid is ignored and the request goes out without a signature.

shouldReturnSignatureWhenMultipleMatchingEidsExist asserts exactly this first-only behaviour, so the test documents the limitation rather than catching it. Suggest iterating the matching eids until one resolves against refs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed

return builder.build();
}

private static ExtUserOptable parseExtUserOptable(JsonNode node) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Three things here:

  • This re-parses user.ext.optable, which IdsMapper has already parsed for the same request.
  • It uses the static ObjectMapperProvider.mapper() rather than the mapper injected into this class, which diverges from how the rest of the module gets its mapper.
  • The parse failure is swallowed silently, whereas IdsMapper logs it. A malformed user.ext.optable will drop the id5 signature with no trace.

Also, the if (extUserOptable.isPresent()) { extUserOptable.map(...).ifPresent(...) } wrapper just above is redundant - the ifPresent already covers it.

id-prefix-order: "e,v,c"
ppid-mapping: { "pubcid.org": "c" }
hid-prefixes: "c,i6"
ppid-mapping: { "pubmatic.com": "c" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unrelated to this PR and looks like a debugging leftover: ppid-mapping changed from {"pubcid.org": "c"} to {"pubmatic.com": "c"}. pubmatic.com is a bidder, not an eid source, so this mapping cannot match anything. Please revert.

Comment thread sample/configs/prebid-config-with-optable.yaml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants