Skip to content

creative_opportunities: express per-section gam_unit_path without one rule per (slot × section) #954

Description

@aram356

Summary

creative_opportunities.slot.gam_unit_path is a static string, so a publisher whose ad unit varies by site section cannot express that in one rule. The only way to model it is one slot rule per (slot × section), which multiplies out fast: a site with 3 slots and 10 sections needs 30 near-identical rules to express a single pattern.

Why it happens

resolved_gam_unit_path is literal-or-default — there is no substitution step:

pub fn resolved_gam_unit_path(&self, gam_network_id: &str) -> String {
    self.gam_unit_path
        .clone()
        .unwrap_or_else(|| format!("/{}/{}", gam_network_id, self.id))
}

crates/trusted-server-core/src/creative_opportunities.rs

So gam_unit_path is either a fixed path or /<network_id>/<slot_id>. Neither can vary by request path, even though page_patterns already matches on exactly that.

Impact

Each duplicated rule repeats id, div_id, formats, and the [creative_opportunities.slot.providers.prebid] sub-table. Only gam_unit_path and page_patterns differ. That means:

  • Config bloat — 30 rules where 3 would do; ~270 lines to express one mapping.
  • Silent drift — adding a format or changing div_id requires editing every section variant. Miss one and that section quietly diverges.
  • Rot on growth — every new site section requires N new rules (one per slot), and forgetting them means those pages fall through to whatever rule matches, bidding on the wrong inventory.

The failure mode is not loud: a rule with the wrong gam_unit_path still returns bids, just against inventory that does not correspond to the page. That is a discrepancy/reporting problem that is easy to ship and hard to notice.

Proposed direction

Some way to express "unit varies by matched section" in a single rule. Options, roughly in order of blast radius:

  1. Placeholder substitution in gam_unit_path, resolved at request time from the matched path — e.g. gam_unit_path = "/12345678/publisher/{section}".
  2. Explicit mapping table on the slot — pattern → unit — keeping resolution data-driven rather than string-templated:
    [[creative_opportunities.slot]]
    id = "ad-header"
    [creative_opportunities.slot.unit_by_pattern]
    "/" = "/12345678/publisher/homepage"
    "/news/*" = "/12345678/publisher/news"
  3. Derivation rule with an explicit default (e.g. first path segment, with a configurable value for /).

Design constraint worth calling out

Whatever the mechanism, the derivation policy is publisher-specific. "First path segment, with / mapping to homepage" is one site's URL convention, not a universal one — it should not be hardcoded in core. Option 2 keeps the policy in config; option 1 needs a well-defined, documented placeholder set plus a decision on what {section} means for /, multi-segment paths, and unmatched routes.

Implementation note

There is exactly one runtime call site:

crates/trusted-server-core/src/publisher.rsbuild_slot_json(slot, co_config)

It does not currently have the request path in scope. It is shared by build_ad_slots_script (initial render) and handle_page_bids (SPA navigation), both of which do know the path, so threading it through is mechanical. CreativeOpportunitySlot::validate also calls resolved_gam_unit_path without a path and would need a path-independent validation story.

Acceptance criteria

  • A publisher with N slots across M sections can express per-section ad units without N×M rules
  • Resolution is covered by tests, including /, multi-segment paths, and paths matching no rule
  • Existing static gam_unit_path configs keep working unchanged
  • validate() still catches an empty/unresolvable unit path
  • Documented in the configuration guide, including what happens on an unmatched route

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions