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
38 changes: 38 additions & 0 deletions hooks/hookexecution/enricher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,44 @@ func TestGetModulesJSON(t *testing.T) {
}
}

func TestGetModulesJSONIncludesHookWarnings(t *testing.T) {
stageOutcomes := []StageOutcome{
{
Stage: "processed_auction_request",
Groups: []GroupOutcome{
{
InvocationResults: []HookOutcome{
{
HookID: HookID{
ModuleCode: "prebid.rulesengine",
HookImplCode: "rulesengine",
},
Status: StatusSuccess,
Warnings: []string{"Bidder [testBidder] was removed from the request by the rules engine"},
},
},
},
},
},
}
bidRequest := &openrtb2.BidRequest{Test: 1, Ext: []byte(`{"prebid":{"trace":"basic"}}`)}
account := &config.Account{DebugAllow: true}

modules, warns, err := GetModulesJSON(stageOutcomes, bidRequest, account)

require.NoError(t, err)
assert.Empty(t, warns)
var modulesOutcome ModulesOutcome
require.NoError(t, jsonutil.UnmarshalValid(modules, &modulesOutcome))
assert.Equal(t, Messages{
"prebid.rulesengine": {
"rulesengine": {"Bidder [testBidder] was removed from the request by the rules engine"},
},
}, modulesOutcome.Warnings)
require.NotNil(t, modulesOutcome.Trace)
assert.Len(t, modulesOutcome.Trace.Stages, 1)
}

func getStageOutcomes(t *testing.T, file string) []StageOutcome {
var stageOutcomes []StageOutcome
var stageOutcomesTest []StageOutcomeTest
Expand Down
4 changes: 4 additions & 0 deletions modules/prebid/rulesengine/bidder_config_ruleset.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ func buildBidderConfigRuleSet(geoscopes map[string][]string, setDefinitions map[
return nil, err
}
crs.modelGroups[0].tree = *tree
// Propagate the analytics key and model version onto the tree so they are available in the
// ResultFunctionMeta at execution time (e.g. for surfacing them in exclusion warnings).
crs.modelGroups[0].tree.AnalyticsKey = crs.modelGroups[0].analyticsKey
crs.modelGroups[0].tree.ModelVersion = crs.modelGroups[0].version

return []cacheRuleSet[RequestWrapper, ProcessedAuctionHookResult]{crs}, nil
}
7 changes: 7 additions & 0 deletions modules/prebid/rulesengine/cache_entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ func createCacheRuleSet(cfg *config.RuleSet) (cacheRuleSet[openrtb_ext.RequestWr
analyticsKey: modelGroup.AnalyticsKey,
tree: *tree,
}
// Propagate the ruleset name, analytics key and model version onto the tree so they are
// available in the ResultFunctionMeta at execution time (e.g. for surfacing them in exclusion
// warnings). The ruleset name is used for display; the analytics key stays exactly as
// configured (it identifies the model group for analytics).
cmg.tree.RulesetName = cfg.Name
cmg.tree.AnalyticsKey = modelGroup.AnalyticsKey
cmg.tree.ModelVersion = modelGroup.Version
crs.modelGroups = append(crs.modelGroups, cmg)
}

Expand Down
10 changes: 8 additions & 2 deletions modules/prebid/rulesengine/hook_processed_auction.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import (

hs "github.com/prebid/prebid-server/v4/hooks/hookstage"
"github.com/prebid/prebid-server/v4/openrtb_ext"
"github.com/prebid/prebid-server/v4/rules"
"github.com/prebid/prebid-server/v4/util/randomutil"
)

type RequestWrapper = openrtb_ext.RequestWrapper
type ModelGroup = cacheModelGroup[RequestWrapper, ProcessedAuctionHookResult]

type ProcessedAuctionHookResult struct {
HookResult hs.HookResult[hs.ProcessedAuctionRequestPayload]
AllowedBidders map[string]struct{}
HookResult hs.HookResult[hs.ProcessedAuctionRequestPayload]
AllowedBidders map[string]struct{}
IncludeContexts []rules.ResultFunctionMeta
}

func handleProcessedAuctionHook(
Expand Down Expand Up @@ -44,6 +46,10 @@ func handleProcessedAuctionHook(
}
}

// Once every ruleset has run the final allow-list is known, so surface a debug warning naming the
// bidders that were implicitly removed by include rules (present in the request but not allowed).
appendInclusionWarnings(payload.Request, &result)

return result.HookResult, nil
}

Expand Down
32 changes: 32 additions & 0 deletions modules/prebid/rulesengine/module_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package rulesengine

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

hs "github.com/prebid/prebid-server/v4/hooks/hookstage"
"github.com/prebid/prebid-server/v4/modules/moduledeps"
"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -107,6 +109,36 @@ func TestBuilderWithWorkingDir(t *testing.T) {

var sampleJsonConfig json.RawMessage = json.RawMessage(`{"enabled": true, "ruleSets": []}`)

// TestHandleProcessedAuctionHookNoConfig verifies that when the account has no rules engine
// configuration, the hook short-circuits and returns an empty result with no warnings or errors,
// without touching the cache or tree manager (so a zero-value Module is safe here).
func TestHandleProcessedAuctionHookNoConfig(t *testing.T) {
tests := []struct {
name string
accountConfig json.RawMessage
}{
{name: "nil_account_config", accountConfig: nil},
{name: "empty_account_config", accountConfig: json.RawMessage{}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := Module{}

result, err := m.HandleProcessedAuctionHook(
context.Background(),
hs.ModuleInvocationContext{AccountID: "account-1", AccountConfig: tt.accountConfig},
hs.ProcessedAuctionRequestPayload{},
)

assert.NoError(t, err)
assert.Equal(t, hs.HookResult[hs.ProcessedAuctionRequestPayload]{}, result)
assert.Empty(t, result.Warnings)
assert.Empty(t, result.Errors)
})
}
}

func TestConfigChanged(t *testing.T) {

testCases := []struct {
Expand Down
212 changes: 212 additions & 0 deletions modules/prebid/rulesengine/result_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"

"github.com/prebid/prebid-server/v4/modules/prebid/rulesengine/config"
"github.com/prebid/prebid-server/v4/openrtb_ext"
Expand Down Expand Up @@ -67,13 +69,118 @@ func (eb *ExcludeBidders) Call(req *openrtb_ext.RequestWrapper, result *Processe
}

result.HookResult.ChangeSet.ProcessedAuctionRequest().Bidders().Delete(excludedBidders)

// Only warn about bidders that are actually present in the request, so the debug output
// reflects what was really removed rather than the rule's full configured exclusion list.
removedBidders := filterPresentBidders(req, eb.Args.Bidders)
if len(removedBidders) > 0 {
warning := buildExclusionWarning(removedBidders, meta)
result.HookResult.Warnings = append(
result.HookResult.Warnings,
warning,
)
}
return nil
}

func (eb *ExcludeBidders) Name() string {
return ExcludeBiddersName
}

// filterPresentBidders returns the subset of the given bidders that appear in at least one
// imp's ext.prebid.bidder map, preserving the input order. It lets exclusion warnings mention
// only the bidders that were really removed from the request instead of the configured list.
func filterPresentBidders(req *openrtb_ext.RequestWrapper, bidders []string) []string {
if req == nil {
return nil
}

present := make(map[string]struct{})
for _, impWrapper := range req.GetImp() {
impExt, err := impWrapper.GetImpExt()
if err != nil {
continue
}
impPrebid := impExt.GetPrebid()
if impPrebid == nil {
continue
}
for bidderName := range impPrebid.Bidder {
present[bidderName] = struct{}{}
}
}

filtered := make([]string, 0, len(bidders))
for _, bidderName := range bidders {
if _, ok := present[bidderName]; ok {
filtered = append(filtered, bidderName)
}
}
return filtered
}

// buildExclusionWarning builds a single human-readable warning line describing why a set of
// bidders was removed from the request by the rules engine. The ruleset label (the configured
// ruleset name, falling back to the model group's analytics key when no name is set) and the
// evaluated conditions (SchemaFunctionResults) are only included when present.
func buildExclusionWarning(bidders []string, meta rules.ResultFunctionMeta) string {
var sb strings.Builder

if len(bidders) == 1 {
sb.WriteString(fmt.Sprintf("Bidder [%s] was removed from the request by the rules engine", bidders[0]))
} else {
sb.WriteString(fmt.Sprintf("Bidders [%s] were removed from the request by the rules engine", strings.Join(bidders, ", ")))
}

// Prefer the human-readable ruleset name; fall back to the analytics key when no name is set.
rulesetLabel := meta.RulesetName
if len(rulesetLabel) == 0 {
rulesetLabel = meta.AnalyticsKey
}
if len(rulesetLabel) > 0 {
sb.WriteString(fmt.Sprintf(" ruleset %q", rulesetLabel))
}

if reason := buildSchemaReason(meta.SchemaFunctionResults); len(reason) > 0 {
sb.WriteString(": ")
sb.WriteString(reason)
}

return sb.String()
}

// buildSchemaReason renders the evaluated schema conditions into a human-readable reason. It is
// generic across every schema function, so any rule added to the engine gets sensible reasoning
// without per-function special casing. It is shared by the exclude and include warning builders.
func buildSchemaReason(steps []rules.SchemaFunctionStep) string {
if len(steps) == 0 {
return ""
}

reasons := make([]string, 0, len(steps))
for _, step := range steps {
reasons = append(reasons, describeSchemaStep(step))
}
return strings.Join(reasons, ", ")
}

// describeSchemaStep turns a single evaluated schema condition into plain text of the form
// "<function> rule evaluated to <result>". It is generic across every schema function, so any rule
// added to the engine reads sensibly without per-function special casing. Boolean results
// (true/false) are rendered unquoted; an empty result (e.g. a value function like deviceCountry
// when the request has no geo) is rendered as "(no value)"; any other result (e.g. a country code
// or channel) is quoted.
func describeSchemaStep(step rules.SchemaFunctionStep) string {
switch step.FuncResult {
case "true", "false":
return fmt.Sprintf("%s rule evaluated to %s", step.FuncName, step.FuncResult)
case "":
return fmt.Sprintf("%s rule evaluated to (no value)", step.FuncName)
default:
return fmt.Sprintf("%s rule evaluated to %q", step.FuncName, step.FuncResult)
}
}

// NewIncludeBidders is a factory function that creates a new IncludeBidders result function.
// It takes a JSON raw message as input, unmarshals it into a slice of ResultFuncParams,
// and returns an IncludeBidders instance.
Expand All @@ -100,9 +207,114 @@ func (ib *IncludeBidders) Call(req *openrtb_ext.RequestWrapper, result *Processe
for _, bidderName := range ib.Args.Bidders {
result.AllowedBidders[bidderName] = struct{}{} // Ensure the bidder is included in the allowed bidders
}
// Record the ruleset context so that, once every ruleset has run and the final allow-list is
// known, we can surface a debug warning naming the bidders that were implicitly removed because
// they were not on any include list.
result.IncludeContexts = append(result.IncludeContexts, meta)
return nil
}

func (ib *IncludeBidders) Name() string {
return IncludeBiddersName
}

// biddersRemovedByInclude returns the bidders present in the request that are not in the final
// allow-list accumulated by the include rules. These are the bidders that will be implicitly
// dropped when the allow-list is applied. The result is sorted for deterministic output.
func biddersRemovedByInclude(req *openrtb_ext.RequestWrapper, allowed map[string]struct{}) []string {
if req == nil {
return nil
}

removed := make(map[string]struct{})
for _, impWrapper := range req.GetImp() {
impExt, err := impWrapper.GetImpExt()
if err != nil {
continue
}
impPrebid := impExt.GetPrebid()
if impPrebid == nil {
continue
}
for bidderName := range impPrebid.Bidder {
if _, ok := allowed[bidderName]; !ok {
removed[bidderName] = struct{}{}
}
}
}

result := make([]string, 0, len(removed))
for bidderName := range removed {
result = append(result, bidderName)
}
sort.Strings(result)
return result
}

// appendInclusionWarnings surfaces a debug warning naming the bidders that an include rule
// implicitly removed from the request (bidders present in the request but absent from every
// include list). It is a no-op when no include rule fired or when nothing was removed.
func appendInclusionWarnings(req *openrtb_ext.RequestWrapper, result *ProcessedAuctionHookResult) {
if result == nil || len(result.IncludeContexts) == 0 {
return
}

removed := biddersRemovedByInclude(req, result.AllowedBidders)
if len(removed) == 0 {
return
}

result.HookResult.Warnings = append(
result.HookResult.Warnings,
buildInclusionWarning(removed, result.IncludeContexts),
)
}

// buildInclusionWarning builds a single human-readable warning line describing why a set of bidders
// was removed from the request because they were not on any include list. Each include rule that
// constrained the request is described by its ruleset label and evaluated conditions when present.
func buildInclusionWarning(bidders []string, contexts []rules.ResultFunctionMeta) string {
var sb strings.Builder

if len(bidders) == 1 {
sb.WriteString(fmt.Sprintf("Bidder [%s] was removed from the request by the rules engine because it was not in the include list", bidders[0]))
} else {
sb.WriteString(fmt.Sprintf("Bidders [%s] were removed from the request by the rules engine because they were not in the include list", strings.Join(bidders, ", ")))
}

descriptions := make([]string, 0, len(contexts))
for _, meta := range contexts {
if desc := describeIncludeContext(meta); len(desc) > 0 {
descriptions = append(descriptions, desc)
}
}
if len(descriptions) > 0 {
sb.WriteString(" applied by ")
sb.WriteString(strings.Join(descriptions, ", "))
}

return sb.String()
}

// describeIncludeContext renders a single include rule's ruleset label (the configured ruleset name,
// falling back to the model group's analytics key) and evaluated conditions into text of the form
// `ruleset "X" (deviceCountry rule evaluated to "JPN")`. Either portion is omitted when not present.
func describeIncludeContext(meta rules.ResultFunctionMeta) string {
rulesetLabel := meta.RulesetName
if len(rulesetLabel) == 0 {
rulesetLabel = meta.AnalyticsKey
}

reason := buildSchemaReason(meta.SchemaFunctionResults)

switch {
case len(rulesetLabel) > 0 && len(reason) > 0:
return fmt.Sprintf("ruleset %q (%s)", rulesetLabel, reason)
case len(rulesetLabel) > 0:
return fmt.Sprintf("ruleset %q", rulesetLabel)
case len(reason) > 0:
return reason
default:
return ""
}
}
Loading
Loading