Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions extra/modules/optable-targeting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ would result in this nesting in the JSON configuration:
| adserver-targeting | no | boolean | false | If set to true - will add the Optable-specific adserver targeting keywords into the PBS response for every `seatbid[].bid[].ext.prebid.targeting` |
| timeout | no | integer | none | A soft timeout (in ms) sent as a hint to the Targeting API endpoint to limit the request times to Optable's external tokenizer services |
| id-prefix-order | no | string | none | An optional string of comma separated id prefixes that prioritizes and specifies the order in which ids are provided to Targeting API in a query string. F.e. "c,c1,id5" will guarantee that Targeting API will see id=c:...,c1:...,id5:... if these ids are provided. id-prefixes not mentioned in this list will be added in arbitrary order after the priority prefix ids. This affects Targeting API processing logic |
| hid-prefixes | no | string | none | An optional string of comma separated id prefixes that should additionally be sent to the Targeting API as resolver hints in `hid=prefix:value` query parameters. See the section on Resolver Hints (hid) below for more detail. |
| enrichment-percentage | no | integer | 100 | Default percentage (0-100) of bid requests per bidder that will receive enrichment data. Set to 100 to enrich all requests, 0 to disable enrichment by default. |
| bidder-enrichment-percentages | no | map | none | Per-bidder overrides for `enrichment-percentage`. Keys are bidder names, values are percentages (0-100). F.e. `{"appnexus": 75, "criteo": 0}` enriches 75% of appnexus requests and none for criteo. Bidders not listed in this map fall back to the default `enrichment-percentage` (100% unless overridden). |
| enrich-web | no | boolean | true | Whether to enrich web traffic (requests with a `site` object). |
Expand Down Expand Up @@ -239,8 +240,8 @@ on identifier types. Targeting API accepts multiple id parameters - and their or

### Optable input erasure

**Note**: `user.ext.optable.email`, `.phone`, `.zip`, `.vid` fields will be removed by the module from the original
OpenRTB request before being sent to bidders.
**Note**: `user.ext.optable.email`, `.phone`, `.zip`, `.vid` and `.id5_signature` fields will be removed by the module
from the original OpenRTB request before being sent to bidders.

### Publisher Provided IDs (PPID) Mapping

Expand All @@ -263,6 +264,52 @@ ppid-mapping: {"id5-sync.com": "c1"}

This will lead to id5 ID supplied as `id=c1:...` to the Targeting API.

### Resolver Hints (`hid`)

In addition to the regular `id=prefix:value` parameters, the module can forward selected identifiers to the Targeting
API as resolver hints using the `hid=prefix:value` query parameter form. The set of prefixes that should be sent as
`hid` is configured via the `hid-prefixes` parameter, which accepts a comma-separated list of prefix names, f.e.:

```yaml
hid-prefixes: "c, i6"
```

## Targeting API Query Attributes

In addition to the identifier parameters, the module forwards the following attributes as query string parameters to
the Targeting API:

| Attribute | Source |
|------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| `gdpr` | `1` if GDPR applies to the request, `0` otherwise. |
| `gdpr_consent` | TCF consent string, sent when available and the consent is valid. |
| `gpp` | GPP string, sent when available in the resolved GPP context. |
| `gpp_sid` | Comma-separated list of active GPP section IDs (limited to the first two), sent when the set is non-empty. |
| `timeout` | Soft timeout hint (in ms, suffixed with `ms`), sent when the module-level `timeout` property is configured. |
| `osdk` | Always set to `prebid-server`, identifying the caller. |
| `bundle` | App bundle identifier, URL-encoded. Sent when the incoming request has an `app` object (`request.app.bundle`) and the bundle is non-empty. |
| `ver` | App version, URL-encoded. Sent only when `bundle` is non-empty and `request.app.ver` is present and non-empty. |
| `id5_signature` | ID5 signature, URL-encoded. Sent when the resolved `user.ext.optable.id5_signature` is present in the incoming request |

### App bundle and version

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
parameter.

### ID5 signature

The ID5 signature is propagated to the Targeting API in two ways:

1. **Request-side signature** — when the incoming OpenRTB request carries `user.ext.optable.id5_signature`, that value
is sent to the Targeting API as the `id5_signature=` query parameter.
2. **Response-side signature** — when the Targeting API response contains refs with an ID5 signature, the module
resolves the signature through the matching optable-eid and propagates it back into the request context, where it
is later injected into the bid response (`ext.prebid.passthrough.optable.id5_signature`) for downstream use.

The `id5_signature` field is also part of the Optable input erasure — see below.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Says the erasure section is "below" when it is above. Also this new section uses em-dashes, which the rest of the doc does not.

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


## Analytics Tags

The following 2 analytics tags are written by the module:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,11 @@ ConfigResolver configResolver(JsonMerger jsonMerger, OptableTargetingProperties

@Bean
TargetingRequestExecutor targetingRequestExecutor(OptableTargeting optableTargeting,
UserFpdActivityMask userFpdActivityMask,
TimeoutFactory timeoutFactory) {
UserFpdActivityMask userFpdActivityMask,
TimeoutFactory timeoutFactory,
@Value("${logging.sampling-rate:0.01}") double logSamplingRate) {

return new TargetingRequestExecutor(optableTargeting, userFpdActivityMask, timeoutFactory);
return new TargetingRequestExecutor(optableTargeting, userFpdActivityMask, timeoutFactory, logSamplingRate);
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.prebid.server.hooks.modules.optable.targeting.model;

import lombok.Value;

@Value(staticConstructor = "of")
public class App {

String bundle;

String ver;
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public class ModuleContext {

private boolean shouldSkipEnrichment;

private String id5Signature;

public static ModuleContext of(AuctionInvocationContext invocationContext) {
final ModuleContext moduleContext = (ModuleContext) invocationContext.moduleContext();
return moduleContext != null ? moduleContext : new ModuleContext();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ public class OptableAttributes {
String userAgent;

Long timeout;

App app;

String id5Signature;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ public class Query {

String ids;

String hid;

String attributes;

public String toQueryString() {
return ids + attributes;
return ids + hid + attributes;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.commons.collections.MapUtils;
import org.apache.commons.collections4.MapUtils;

import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -33,6 +33,9 @@ public final class OptableTargetingProperties {
@JsonProperty("id-prefix-order")
String idPrefixOrder;

@JsonProperty("hid-prefixes")
String hidPrefixes;

@JsonProperty("optable-inserter-eids-merge")
Set<String> optableInserterEidsMerge = Set.of();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ public class ExtUserOptable extends FlexibleExtension {
String zip;

String vid;

String id5Signature;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.prebid.server.hooks.modules.optable.targeting.model.openrtb;

import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.Value;

import java.util.List;
Expand All @@ -10,4 +11,6 @@ public class TargetingResult {
List<Audience> audience;

Ortb2 ortb2;

ObjectNode refs;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.AnalyticTagsResolver;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidderRequestEnricher;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5Resolver;
import org.prebid.server.hooks.v1.InvocationAction;
import org.prebid.server.hooks.v1.InvocationResult;
import org.prebid.server.hooks.v1.InvocationStatus;
Expand Down Expand Up @@ -61,6 +62,7 @@ private Future<InvocationResult<BidderRequestPayload>> enrichedPayload(Targeting

if (hasData) {
moduleContext.setTargeting(targetingResult.getAudience());
moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New issue introduced by the refactor: the signature is silently dropped in the raw+bidder plan when per-bidder enrichment is off.

This is now the only place the signature is set in that plan, but the hook returns early at lines 37-39 when properties.isPerBidderEnrichmentEnabled() is false - and that is false for the default config, since it requires enrichmentPercentage != 100 || !bidderEnrichmentPercentages.isEmpty() (OptableTargetingProperties.java:59-61). With both hooks in the execution plan, OptableTargetingProcessedAuctionRequestHook.java:76-78 also returns without setting it.

So with enrichment-percentage: 100 and no per-bidder map, moduleContext.getId5Signature() is null at response time and the signature never reaches the client. At 073b1d543 the response hook resolved it from the future's TargetingResult, so it still got through. Behavior regression from the fix-up, and no test covers this combination.

moduleContext.setEnrichRequestStatus(EnrichmentStatus.success());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.prebid.server.hooks.modules.optable.targeting.v1.core.AuctionResponseValidator;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidResponseEnricher;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5SignatureBidResponseEnricher;
import org.prebid.server.hooks.v1.InvocationAction;
import org.prebid.server.hooks.v1.InvocationResult;
import org.prebid.server.hooks.v1.InvocationStatus;
Expand Down Expand Up @@ -62,15 +63,38 @@ public Future<InvocationResult<AuctionResponsePayload>> call(AuctionResponsePayl

return validationStatus.getStatus() == Status.SUCCESS
? enrichedPayload(moduleContext)
: success(moduleContext);
: enrichedById5SignaturePayload(moduleContext);
}

private Future<InvocationResult<AuctionResponsePayload>> enrichedPayload(ModuleContext moduleContext) {
final List<Audience> targeting = moduleContext.getTargeting();
final String id5Signature = moduleContext.getId5Signature();

return CollectionUtils.isNotEmpty(targeting)
? update(BidResponseEnricher.of(targeting, objectMapper, jsonMerger), moduleContext)
: success(moduleContext);
? update(fullEnrichmentChain(targeting, id5Signature), moduleContext)
: update(id5SignatureEnrichmentChain(id5Signature), moduleContext);
}

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

final String id5Signature = moduleContext.getId5Signature();

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

@eugenedorfman-optable eugenedorfman-optable Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still blocking: the NPE moved but did not go away. It also keeps the 10 ms stall flagged in the last review alive.

The keyword path is now synchronous and correct, but this branch still dereferences the future unguarded. Every path where the future was never set also has null targeting, so AuctionResponseValidator.checkEnrichmentPossibility returns NOKEYWORD (AuctionResponseValidator.java:21-22) and control lands exactly here. Live paths: the raw-hook early returns at OptableRawAuctionRequestHook.java:73,79,84 (the shipped sample pubmatic: 0 reaches this), a fresh ModuleContext, and the deprecated processed-only plan whenever the API returns no audience or the response has no bids.

Reproduced - a ModuleContext with no optableTargetingCall and no targeting, plus a bid response:

java.lang.NullPointerException: Cannot invoke "io.vertx.core.Future.compose(java.util.function.Function)"
because the return value of "...ModuleContext.getOptableTargetingCall()" is null

Note the compose is dead weight regardless: targetingResult is ignored and id5Signature is read from the context on the line above. Deleting the compose and calling update(id5SignatureEnrichmentChain(id5Signature), moduleContext) directly fixes the NPE and removes the last place the response hook can stall on a pending network call under the auction-response group's timeout: 10.

The existing tests pass because they all hand the hook a future. Please add one that does not.

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

.compose(targetingResult ->
update(id5SignatureEnrichmentChain(id5Signature), moduleContext))
.recover(throwable -> success(moduleContext));
}

private PayloadUpdate<AuctionResponsePayload> fullEnrichmentChain(final List<Audience> targeting,
final String id5Signature) {

return BidResponseEnricher.of(targeting, objectMapper, jsonMerger)
.andThen(Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger))::apply;
}

private PayloadUpdate<AuctionResponsePayload> id5SignatureEnrichmentChain(String id5Signature) {
return Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger);
}

private Future<InvocationResult<AuctionResponsePayload>> update(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidRequestEnricher;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.CompositeHookExecutionPlan;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.TargetingRequestExecutor;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5Resolver;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.PropertiesValidator;
import org.prebid.server.hooks.modules.optable.targeting.v1.core.TargetingRequestExecutor;
import org.prebid.server.hooks.v1.InvocationAction;
import org.prebid.server.hooks.v1.InvocationResult;
import org.prebid.server.hooks.v1.InvocationStatus;
Expand Down Expand Up @@ -105,6 +106,7 @@ private Future<InvocationResult<AuctionRequestPayload>> enrichPayload(
OptableTargetingProperties properties) {

moduleContext.setTargeting(targetingResult.getAudience());
moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult));
moduleContext.setEnrichRequestStatus(EnrichmentStatus.success());

final PayloadUpdate<AuctionRequestPayload> payloadUpdate =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
public class BidRequestCleaner implements PayloadUpdate<AuctionRequestPayload> {

private static final String OPTABLE_FIELD = "optable";
private static final List<String> FIELDS_TO_REMOVE = List.of("email", "phone", "zip", "vid");
private static final List<String> FIELDS_TO_REMOVE = List.of("email", "phone", "zip", "vid", "id5_signature");

public static BidRequestCleaner instance() {
return new BidRequestCleaner();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.prebid.server.hooks.modules.optable.targeting.v1.core;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable;
import org.prebid.server.json.ObjectMapperProvider;
import org.prebid.server.log.ConditionalLogger;
import org.prebid.server.log.LoggerFactory;

public class ExtUserOptableResolver {

private static final ConditionalLogger conditionalLogger =
new ConditionalLogger(LoggerFactory.getLogger(ExtUserOptableResolver.class));

private ExtUserOptableResolver() {
}

public static ExtUserOptable resolveExtUserOptable(JsonNode node, double logSamplingRate) {
try {
return ObjectMapperProvider.mapper().treeToValue(node, ExtUserOptable.class);
} catch (JsonProcessingException e) {
conditionalLogger.warn("Can't parse $.ext.user.Optable tag", logSamplingRate);
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package org.prebid.server.hooks.modules.optable.targeting.v1.core;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.iab.openrtb.request.Eid;
import com.iab.openrtb.request.Uid;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Ortb2;
import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User;

import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

public class Id5Resolver {

public static final String STR_OPTABLE_CO = "optable.co";
public static final String STR_ID_5_SYNC_COM = "id5-sync.com";
public static final String STR_OPTABLE = "optable";
public static final String STR_REF = "ref";
public static final String STR_SIGNATURE = "signature";

private Id5Resolver() {
}

public static String resolveId5Signature(TargetingResult targetingResult) {
if (targetingResult == null) {
return null;
}

final List<String> refs = 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)
.filter(Objects::nonNull)
.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)
.toList();

if (CollectionUtils.isEmpty(refs)) {
return null;
}

final ObjectNode references = targetingResult.getRefs();
if (references == null) {
return null;
}

return refs.stream()
.map(references::get)
.filter(Objects::nonNull)
.map(it -> it.get(STR_SIGNATURE))
.filter(Objects::nonNull)
.filter(JsonNode::isValueNode)
.map(JsonNode::asText)
.filter(StringUtils::isNotBlank)
Comment on lines +65 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New issue introduced here: this can return the literal string "null".

A refs entry whose signature is JSON null gives a NullNode. NullNode.isValueNode() is true, so it passes the filter; asText() on it yields "null"; and isNotBlank("null") is true. The string "null" is then sent as the id5_signature= query param and written into the client passthrough.

Reproduced with a refs entry of {"signature": null}:

expected: null
 but was: "null"

Fix: .filter(it -> !it.isNull()) before .map(JsonNode::asText).

Unrelated nit while here: STR_OPTABLE_CO / STR_ID_5_SYNC_COM is not the naming style used elsewhere in PBS - OPTABLE_INSERTER / ID5_SOURCE reads better.

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

.findFirst()
.orElse(null);
}
}
Loading