optable-targeting: Hid plus id5 Mobile In-app resolver params - #3
Conversation
eugenedorfman-optable
left a comment
There was a problem hiding this comment.
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_COMis not the naming convention used elsewhere in PBS;OPTABLE_INSERTER/ID5_SOURCEwould read better.QueryBuilder.java:61usesString.formatwhere the surrounding code uses.formatted().
| return builder.build(); | ||
| } | ||
|
|
||
| private static ExtUserOptable parseExtUserOptable(JsonNode node) { |
There was a problem hiding this comment.
Three things here:
- This re-parses
user.ext.optable, whichIdsMapperhas 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
IdsMapperlogs it. A malformeduser.ext.optablewill 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.
There was a problem hiding this comment.
Re-review at 903aebb
Encoding of the query params, the Id5Resolver first-match limitation, the OptableAttributesResolver re-parse and the dead MapUtils.isEmpty check are resolved. The rest is below.
I built the module and ran the suite this time rather than reviewing by inspection: 192 tests, all green at 903aebb2a. So the remaining findings are things the tests do not reach, not things the tests disagree with. Two of them I turned into executable repros - the output is quoted in the relevant inline comments.
Still open: the NPE in the auction-response hook (still reachable, and it also keeps the 10 ms stall alive), the cache key (file untouched), the hid-prefixes handling (partially addressed), and the sample-config endpoint drive-by (still in the diff). Two new issues introduced by the fix-up are flagged inline as well.
Happy to share the two repro tests as a starting point for regression coverage if useful.
eugenedorfman-optable
left a comment
There was a problem hiding this comment.
Review of the hid + ID5 in-app resolver changes. Every point below except the dead objectMapper is posted as an applicable suggestion, so it can be taken with the "Add suggestion to batch" button and committed in one go. That one lands mostly on lines outside the diff, so it is spelled out as a patch instead.
Needs fixing
- Every app request sends
bundle,verandid5_signaturetwice, because bothbuildAttributesStringandbuildHidAttributesStringemit them andtoQueryString()concatenates both. - A failed targeting call now fails the bidder request hook on the not-enriched path, where it used to return
no_action. IdsMapper.objectMapperis dead afterparseExtUserOptablewas removed.- Four em-dashes in the README.
Plus three regression tests for the gaps that let 1 and 2 through, and one for the hid-prefixes trim() this PR adds without asserting it.
Two things worth a decision rather than a patch
- Serializing the bidder requests behind the targeting call is what makes the signature propagation work, but it moves the targeting latency onto the bidder request path for every bidder, including the ones that are not enriched. Intentional?
- The auction response hook now returns
updateunconditionally, even when there is no signature and no advertiser targeting to write. That is observability noise (the analytics tag says the payload changed when it did not) rather than a bug, but it makes the module's tags harder to read.
| return sb.toString(); | ||
| } | ||
|
|
||
| private static String buildHidAttributesString(OptableAttributes optableAttributes) { |
There was a problem hiding this comment.
Worth saying why the same attributes are built in a separate method, so the next reader does not "fix" it by merging the two back together:
| private static String buildHidAttributesString(OptableAttributes optableAttributes) { | |
| // Kept apart from buildAttributesString because these are the only attributes that | |
| // discriminate one user from another, so they have to take part in the cache key. | |
| private static String buildHidAttributesString(OptableAttributes optableAttributes) { |
|
|
||
| For app traffic (requests with an `app` object) the module forwards the application's bundle identifier as the | ||
| `bundle=` query parameter and, when available, its version as `ver=`. Both values are URL-encoded. The `ver` parameter | ||
| is only sent when `bundle` is present and non-empty — requests with a version but no bundle will not produce either |
There was a problem hiding this comment.
Em-dash:
| is only sent when `bundle` is present and non-empty — requests with a version but no bundle will not produce either | |
| is only sent when `bundle` is present and non-empty; requests with a version but no bundle will not produce either |
There was a problem hiding this comment.
Re-review at b8441d5
Went back over the whole PR and over every open thread. Everything raised in the first two rounds is now either verified fixed or settled by your reply, the itemised confirmations are in the follow-up comment. Nothing from a rejected thread is being raised again.
Still open from the last round, unchanged at b8441d5, all with applicable suggestions already attached:
bundle,verandid5_signatureare emitted twice per app request (buildAttributesStringandbuildHidAttributesStringboth write them,toQueryString()concatenates both).noActioninOptableBidderRequestHookhas no failure branch, so a failed targeting call now fails the hook on the path where the bidder is not enriched.objectMapperinIdsMapperis dead since the parsing moved toExtUserOptableResolver.- Three regression tests for the gaps that let the first two through, plus the
hid-prefixestrim().
One thing on the duplication fix, since it interacts with the cache key you just landed: the copies have to come out of buildAttributesString, not buildHidAttributesString. Only hidAttributes feeds createCachingKey, so removing the wrong copy would silently stop the key varying with bundle, version and signature, and requests from different apps would start sharing cached targeting.
One new finding below.
There was a problem hiding this comment.
Status of the earlier rounds, verified against the code at b8441d5.
Round 2:
- Auction response hook NPE - fixed at 9f5bef2. The hook no longer touches
getOptableTargetingCall()at all, so the NPE is structurally gone and the 10 ms stall with it. Every test inOptableTargetingAuctionResponseHookTestnow exercises the no-future case. - Cache key - fixed at 144d1e6.
createCachingKeyfolds inids,hidandhidAttributes, andCachedAPIClientTestasserts hid, bundle and id5_signature each reach the key. Id5Resolverreturning the literal"null"- fixed at a54cecd by theisNullfilter.- Signature dropped when per-bidder enrichment is off - fixed at 9f5bef2. That fix is what left the compose without a failure branch, which is the open
recoverthread. logSamplingRatenot final - fixed.- README "below" vs "above" - fixed. The em-dashes were still in the file after that commit; removed in b8441d5.
- Unknown
hid-prefixestoken dropped silently - your call stands, withdrawn. The trim test is the only part still outstanding and it has its own thread onQueryBuilderTest.
Round 1 is entirely closed: query param encoding, the dead MapUtils check, first-ref-only resolution, the shared ext.user.optable parsing, and the ppid-mapping leftover are all confirmed fixed; ver without bundle and the na.edge endpoint are settled as your call and will not come up again.
The open items are unchanged from the previous comment.
eugenedorfman-optable
left a comment
There was a problem hiding this comment.
Product decision: no ID5 signature when nothing was enriched
We have decided that the ID5 signature should only be delivered when the module actually enriched something. If there was no enrichment, the signature is not wanted.
That is a deliberate scope reduction on our side, not a defect report, and it goes against what this PR currently implements. Flagging it now rather than after merge, and happy to hear the case for the current behaviour if there is one.
What it changes
Two places carry the signature onto no-enrichment paths today:
-
OptableBidderRequestHook.noActioncomposes on the targeting future purely to resolve the signature for bidders the module is not enriching. Under this decision it does not need to wait at all and can return immediately as it did before the PR. This also removes the latency question raised earlier: the early-call design exists so that non-enriched bidders are never held behind the targeting call, and today they are. -
OptableTargetingAuctionResponseHookreturnsupdatewith a signature-only enrichment chain on every no-enrichment path, including whenshouldSkipEnrichmentis set and when validation fails. Under this decision those go back tono_action, and the signature survives only insidefullEnrichmentChain, that is, only when there is targeting to write. As a side effect the analytics tags stop reporting a payload change on auctions the module did not touch.
Consequence for the open recover suggestion
The suggestion on noAction about the missing failure branch is superseded by this. If the compose goes away entirely there is no failure branch to add, so please take one or the other, not both. This direction is the simpler of the two.
Tests that encode the current intent
These seven assert the behaviour being removed and would need to go or be inverted, which is the clearest evidence that the current behaviour was deliberate:
shouldEnrichBidResponseWithId5SignatureOnlyWhenNoTargetingshouldReturnUpdateActionWithId5SignatureWhenSkipEnrichmentIsTrueshouldReturnUpdateActionWhenOptableTargetingCallFailsAndTargetingIsEmptyshouldReturnResultWithUpdateActionWhenTargetingCallFailsAndNoBidsshouldReturnResultWithUpdateActionAndWithPBSAnalyticsTagsshouldSetId5SignatureOnModuleContextWhenPerBidderEnrichmentIsDisabledAndTargetingCallIsPresentshouldSetId5SignatureOnModuleContextWhenBidderNotInEnrichmentSetAndTargetingCallIsPresent
The production change
11 insertions, 40 deletions across the two hooks:
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java
index 6128343a4..a8926b0de 100644
--- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java
@@ -84,7 +84,7 @@ public class OptableBidderRequestHook implements BidderRequestHook {
final Tags analyticsTags = AnalyticTagsResolver.toBidderEnrichRequestAnalyticTags(
bidder, outcome, executionTime);
- return noActionResponse(moduleContext, analyticsTags);
+ return noAction(moduleContext, analyticsTags);
}
private static boolean hasEnrichmentData(TargetingResult targetingResult) {
@@ -100,27 +100,6 @@ public class OptableBidderRequestHook implements BidderRequestHook {
}
private Future<InvocationResult<BidderRequestPayload>> noAction(ModuleContext moduleContext, Tags analyticsTags) {
- final Future<TargetingResult> targetingCall = moduleContext.getOptableTargetingCall();
- if (targetingCall != null) {
- return targetingCall.compose(targetingResult -> {
- moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult));
-
- return Future.succeededFuture(
- InvocationResultImpl.<BidderRequestPayload>builder()
- .status(InvocationStatus.success)
- .action(InvocationAction.no_action)
- .analyticsTags(analyticsTags)
- .moduleContext(moduleContext)
- .build());
- });
- }
-
- return noActionResponse(moduleContext, analyticsTags);
- }
-
- private Future<InvocationResult<BidderRequestPayload>> noActionResponse(ModuleContext moduleContext,
- Tags analyticsTags) {
-
return Future.succeededFuture(
InvocationResultImpl.<BidderRequestPayload>builder()
.status(InvocationStatus.success)
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java
index 8b0e233a0..4d714f5d4 100644
--- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java
@@ -63,24 +63,15 @@ public class OptableTargetingAuctionResponseHook implements AuctionResponseHook
return validationStatus.getStatus() == Status.SUCCESS
? enrichedPayload(moduleContext)
- : enrichedById5SignaturePayload(moduleContext);
+ : success(moduleContext);
}
private Future<InvocationResult<AuctionResponsePayload>> enrichedPayload(ModuleContext moduleContext) {
final List<Audience> targeting = moduleContext.getTargeting();
- final String id5Signature = moduleContext.getId5Signature();
return CollectionUtils.isNotEmpty(targeting)
- ? update(fullEnrichmentChain(targeting, id5Signature), moduleContext)
- : update(id5SignatureEnrichmentChain(id5Signature), moduleContext);
- }
-
- private Future<InvocationResult<AuctionResponsePayload>> enrichedById5SignaturePayload(
- ModuleContext moduleContext) {
-
- final String id5Signature = moduleContext.getId5Signature();
-
- return update(id5SignatureEnrichmentChain(id5Signature), moduleContext);
+ ? update(fullEnrichmentChain(targeting, moduleContext.getId5Signature()), moduleContext)
+ : success(moduleContext);
}
private PayloadUpdate<AuctionResponsePayload> fullEnrichmentChain(final List<Audience> targeting,
@@ -90,10 +81,6 @@ public class OptableTargetingAuctionResponseHook implements AuctionResponseHook
.andThen(Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger))::apply;
}
- private PayloadUpdate<AuctionResponsePayload> id5SignatureEnrichmentChain(String id5Signature) {
- return Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger);
- }
-
private Future<InvocationResult<AuctionResponsePayload>> update(
PayloadUpdate<AuctionResponsePayload> payloadUpdate,
ModuleContext moduleContext) {
@@ -109,8 +96,13 @@ public class OptableTargetingAuctionResponseHook implements AuctionResponseHook
}
private Future<InvocationResult<AuctionResponsePayload>> success(ModuleContext moduleContext) {
- final String id5Signature = moduleContext.getId5Signature();
- return update(id5SignatureEnrichmentChain(id5Signature), moduleContext);
+ return Future.succeededFuture(
+ InvocationResultImpl.<AuctionResponsePayload>builder()
+ .status(InvocationStatus.success)
+ .action(InvocationAction.no_action)
+ .moduleContext(moduleContext)
+ .analyticsTags(AnalyticTagsResolver.toEnrichResponseAnalyticTags(moduleContext))
+ .build());
}
@OverrideIf you disagree and there is a client-side reason the SDK needs the signature refreshed on auctions where nothing was enriched, say so and we will leave it as is.
eugenedorfman-optable
left a comment
There was a problem hiding this comment.
Decoupling the ID5 signature from adserver-targeting: the two are separate features and should not share a switch. Signature delivery should depend only on whether enrichment happened. Suggestion inline, applicable as-is.
| return success(moduleContext); | ||
| } | ||
|
|
||
| final List<Audience> targeting = moduleContext.getTargeting(); | ||
| final String id5Signature = moduleContext.getId5Signature(); | ||
|
|
||
| final EnrichmentStatus validationStatus = AuctionResponseValidator.checkEnrichmentPossibility( | ||
| auctionResponsePayload.bidResponse(), moduleContext.getTargeting()); | ||
| auctionResponsePayload.bidResponse(), targeting); | ||
| moduleContext.setEnrichResponseStatus(validationStatus); | ||
|
|
||
| return validationStatus.getStatus() == Status.SUCCESS | ||
| ? enrichedPayload(moduleContext) | ||
| : success(moduleContext); | ||
| ? update(fullEnrichmentChain(targeting, id5Signature), moduleContext) | ||
| : update(id5SignatureEnrichmentChain(id5Signature), moduleContext); | ||
| } |
There was a problem hiding this comment.
adserver-targeting and the ID5 signature are different features sharing one switch here. adserver-targeting controls the audience keywords written into the bid response for the ad server. The signature is not keywords: it is handed back to the client, stored there, and sent up on the next targeting call. A publisher who wants bidder-request enrichment and signature refresh but does not want Optable keywords in GAM currently loses the signature, silently.
The right condition is whether enrichment actually happened, which is already expressed by the signature itself: moduleContext.getId5Signature() is set only inside the hasData branch of the bidder request hook, and Id5Resolver filters null and blank values before it gets there, so a null check is sufficient and no extra import is needed.
This also settles the validation-failure branch on the same rule. checkEnrichmentPossibility returns SUCCESS only when targeting is non-empty and bids exist, so the other branch is the no-keyword case: it should still deliver a signature when there is one, and return no_action when there is not. Today it returns update unconditionally, so on auctions with no signature the module reports a payload change that Id5SignatureBidResponseEnricher then declines to make, which makes the analytics tags unreadable for working out which auctions the module actually touched.
| return success(moduleContext); | |
| } | |
| final List<Audience> targeting = moduleContext.getTargeting(); | |
| final String id5Signature = moduleContext.getId5Signature(); | |
| final EnrichmentStatus validationStatus = AuctionResponseValidator.checkEnrichmentPossibility( | |
| auctionResponsePayload.bidResponse(), moduleContext.getTargeting()); | |
| auctionResponsePayload.bidResponse(), targeting); | |
| moduleContext.setEnrichResponseStatus(validationStatus); | |
| return validationStatus.getStatus() == Status.SUCCESS | |
| ? enrichedPayload(moduleContext) | |
| : success(moduleContext); | |
| ? update(fullEnrichmentChain(targeting, id5Signature), moduleContext) | |
| : update(id5SignatureEnrichmentChain(id5Signature), moduleContext); | |
| } | |
| return id5SignatureOnlyPayload(moduleContext); | |
| } | |
| final List<Audience> targeting = moduleContext.getTargeting(); | |
| final EnrichmentStatus validationStatus = AuctionResponseValidator.checkEnrichmentPossibility( | |
| auctionResponsePayload.bidResponse(), targeting); | |
| moduleContext.setEnrichResponseStatus(validationStatus); | |
| return validationStatus.getStatus() == Status.SUCCESS | |
| ? update(fullEnrichmentChain(targeting, moduleContext.getId5Signature()), moduleContext) | |
| : id5SignatureOnlyPayload(moduleContext); | |
| } | |
| // adserver-targeting governs the keywords written for the ad server; the ID5 signature is a | |
| // separate client-side concern, so it rides on whether enrichment happened, not on that flag. | |
| private Future<InvocationResult<AuctionResponsePayload>> id5SignatureOnlyPayload(ModuleContext moduleContext) { | |
| final String id5Signature = moduleContext.getId5Signature(); | |
| return id5Signature != null | |
| ? update(id5SignatureEnrichmentChain(id5Signature), moduleContext) | |
| : success(moduleContext); | |
| } |
Three tests assert update on paths that become no_action under this and need their expectation flipped. All three are failed-targeting-call cases with no signature, so no_action is the correct new expectation:
shouldReturnResultWithUpdateActionAndWithPBSAnalyticsTagsshouldReturnResultWithUpdateActionWhenTargetingCallFailsAndNoBidsshouldReturnUpdateActionWhenOptableTargetingCallFailsAndTargetingIsEmpty
shouldEnrichBidResponseWithId5SignatureOnlyWhenNoTargeting and shouldReturnNoActionWhenAdserverTargetingIsDisabled both still pass. Worth adding one more: adserver targeting off with a signature present should deliver the signature.
With this applied the module suite is 208 passing and those 3 failing.
eugenedorfman-optable
left a comment
There was a problem hiding this comment.
The split is right. adserver-targeting: false with a signature present now delivers it, adserver-targeting: true with validation failing delivers it, and every path with no signature returns no_action with a null payload update, so the action no longer claims a change the enricher declines to make. setAdserverTargetingEnabled and setEnrichResponseStatus are still set on the same paths as before, and the analytics tags still describe what actually happened.
The new tests pin both halves properly: each early-return path has a no-signature twin asserting no_action and a null payloadUpdate, and a with-signature twin asserting the exact passthrough value. 216 tests pass.
One test is miswired, inline below. Nothing else outstanding on this PR from my side.
| when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); | ||
|
|
||
| // when | ||
| final Future<InvocationResult<AuctionResponsePayload>> future = | ||
| target.call(auctionResponsePayload, invocationContext); | ||
| final InvocationResult<AuctionResponsePayload> result = future.result(); | ||
| final BidResponse bidResponse = result | ||
| .payloadUpdate() | ||
| .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) | ||
| .bidResponse(); |
There was a problem hiding this comment.
This does not test what its name says. givenBidResponse() returns a response with one seatbid containing one bid, and the test also sets non-empty targeting, so checkEnrichmentPossibility returns SUCCESS rather than FAIL/NOBID. The hook therefore takes the full enrichment chain, not the signature-only branch. It passes because the full chain writes the signature too, and nothing here asserts that keywords are absent.
So the NOBID-with-signature path has no coverage. shouldReturnNoActionWhenTargetingCallFailsAndNoBidsAndNoId5Signature is fine, since leaving bidResponse() unstubbed yields null and hasBids is null-safe.
A bid-less response puts it on the branch it names:
| when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); | |
| // when | |
| final Future<InvocationResult<AuctionResponsePayload>> future = | |
| target.call(auctionResponsePayload, invocationContext); | |
| final InvocationResult<AuctionResponsePayload> result = future.result(); | |
| final BidResponse bidResponse = result | |
| .payloadUpdate() | |
| .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) | |
| .bidResponse(); | |
| final BidResponse bidlessResponse = BidResponse.builder().build(); | |
| when(auctionResponsePayload.bidResponse()).thenReturn(bidlessResponse); | |
| // when | |
| final Future<InvocationResult<AuctionResponsePayload>> future = | |
| target.call(auctionResponsePayload, invocationContext); | |
| final InvocationResult<AuctionResponsePayload> result = future.result(); | |
| final BidResponse bidResponse = result | |
| .payloadUpdate() | |
| .apply(AuctionResponsePayloadImpl.of(bidlessResponse)) | |
| .bidResponse(); |
Verified: compiles and passes as-is, no new imports needed. Asserting moduleContext.getEnrichResponseStatus() carries Reason.NOBID would pin the branch harder, but that needs an import so I have left it out of the suggestion.
… resolving id5Signature
…re not participate in targeting enrichment process.
The test named for the NOBID branch stubbed a bid response that has a bid, so with non-empty targeting the validator returned SUCCESS and the test exercised the full enrichment chain instead of the signature-only one.
87b6b8d to
765a538
Compare
.cursorrules, GEMINI.md, .gemini/ and extra/.vscode_old/ are local tooling config that got swept into the branch during a rebase. Nothing references them.
🔧 Type of changes
What's the context?
This update introduces hid (resolver hint) support together with the ID5 Mobile In-App resolver additions.
New features
hid-prefixes— a comma-separated set of id prefixes to emit ashid=, 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.Bundleandverparams 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).id5_signaturefrom client and propagate it as Targeting API query attribute (alongside email/phone/zip/vid).ext.prebid.passthrough.optable.id5_signatureon the next auction (step 3)🧪 Test plan
🏎 Quality check