Skip to content
Draft
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
278 changes: 277 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ handlebars = "6.4.0"
hex = "0.4.3"
hmac = "0.12.1"
http = "1.4.0"
iab_gpp = "0.1"
jose-jwk = "0.1.2"
log = "0.4.29"
log-fastly = "0.11.12"
Expand All @@ -79,3 +80,4 @@ urlencoding = "2.1"
uuid = { version = "1.18", features = ["v4"] }
validator = { version = "0.20", features = ["derive"] }
which = "8"
criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] }
6 changes: 6 additions & 0 deletions crates/common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ handlebars = { workspace = true }
hex = { workspace = true }
hmac = { workspace = true }
http = { workspace = true }
iab_gpp = { workspace = true }
jose-jwk = { workspace = true }
log = { workspace = true }
rand = { workspace = true }
Expand Down Expand Up @@ -67,5 +68,10 @@ validator = { workspace = true }
default = []

[dev-dependencies]
criterion = { workspace = true }
temp-env = { workspace = true }
tokio-test = { workspace = true }

[[bench]]
name = "consent_decode"
harness = false
236 changes: 236 additions & 0 deletions crates/common/benches/consent_decode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
//! Benchmarks for the consent decoding pipeline.
//!
//! Measures the computational cost of decoding consent signals (TCF v2, GPP,
//! US Privacy) to determine whether wiring decoding into the auction hot path
//! introduces unacceptable latency.
//!
//! Run with: `cargo bench -p trusted-server-common`

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};

use trusted_server_common::consent::tcf::decode_tc_string;
use trusted_server_common::consent::types::RawConsentSignals;
use trusted_server_common::consent::us_privacy::decode_us_privacy;
use trusted_server_common::consent::{build_context_from_signals, gpp};

// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------

/// Known-good GPP string with US Privacy section only (section ID 6).
const GPP_USP_ONLY: &str = "DBABTA~1YNN";

/// GPP string with both TCF EU v2 and US Privacy sections.
const GPP_TCF_AND_USP: &str = "DBACNY~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNN";

/// Builds a minimal TC String v2 byte buffer for benchmarking.
///
/// This duplicates the test helper from `tcf.rs` since `#[cfg(test)]` helpers
/// are not available in bench targets.
fn build_tc_bytes(vendor_count: u16, use_range_encoding: bool) -> Vec<u8> {
let total_bits = if use_range_encoding {
// Core fields (213) + maxVendorId (16) + isRange (1) + numEntries (12)
// + one range entry per vendor group: isRange(1) + start(16) + end(16)
// We'll encode as one big range: vendors 1..=vendor_count
213 + 17 + 12 + 1 + 32
} else {
// Bitfield: core fields + maxVendorId + isRange + one bit per vendor
213 + 17 + usize::from(vendor_count)
};
let total_bytes = total_bits.div_ceil(8);
let mut buf = vec![0u8; total_bytes];

// Version (6 bits) = 2
write_bits(&mut buf, 0, 6, 2);
// Created (36 bits) = 100000 (arbitrary)
write_bits(&mut buf, 6, 36, 100_000);
// LastUpdated (36 bits) = 200000
write_bits(&mut buf, 42, 36, 200_000);
// CmpId (12 bits) = 7
write_bits(&mut buf, 78, 12, 7);
// CmpVersion (12 bits) = 1
write_bits(&mut buf, 90, 12, 1);
// ConsentScreen (6 bits) = 1
write_bits(&mut buf, 102, 6, 1);
// ConsentLanguage (12 bits) = EN
write_bits(&mut buf, 108, 6, u64::from(b'E' - b'A'));
write_bits(&mut buf, 114, 6, u64::from(b'N' - b'A'));
// VendorListVersion (12 bits) = 42
write_bits(&mut buf, 120, 12, 42);
// TcfPolicyVersion (6 bits) = 2
write_bits(&mut buf, 132, 6, 2);
// IsServiceSpecific (1) = 0, UseNonStandardTexts (1) = 0
// SpecialFeatureOptIns (12) = 0b000000000011 (features 11, 12)
write_bits(&mut buf, 140, 12, 0b0000_0000_0011);
// PurposesConsent (24) = purposes 1-4 consented
write_bits(&mut buf, 152, 24, 0b1111_0000_0000_0000_0000_0000);
// PurposesLITransparency (24) = purposes 1-2
write_bits(&mut buf, 176, 24, 0b1100_0000_0000_0000_0000_0000);
// PurposeOneTreatment (1) = 0
// PublisherCC (12) = EN
write_bits(&mut buf, 201, 6, u64::from(b'E' - b'A'));
write_bits(&mut buf, 207, 6, u64::from(b'N' - b'A'));

// MaxVendorConsentId (16)
write_bits(&mut buf, 213, 16, u64::from(vendor_count));

if use_range_encoding {
// IsRangeEncoding (1) = 1
write_bit(&mut buf, 229, true);
// NumEntries (12) = 1 (one range covering all vendors)
write_bits(&mut buf, 230, 12, 1);
// Entry: IsRangeEntry (1) = 1
write_bit(&mut buf, 242, true);
// StartVendorId (16) = 1
write_bits(&mut buf, 243, 16, 1);
// EndVendorId (16) = vendor_count
write_bits(&mut buf, 259, 16, u64::from(vendor_count));
} else {
// IsRangeEncoding (1) = 0 (bitfield)
write_bit(&mut buf, 229, false);
// Set every other vendor as consented (realistic pattern)
for i in 0..usize::from(vendor_count) {
if i % 2 == 0 {
write_bit(&mut buf, 230 + i, true);
}
}
}

buf
}

fn write_bit(buf: &mut [u8], bit_offset: usize, value: bool) {
if value {
let byte_idx = bit_offset / 8;
let bit_idx = 7 - (bit_offset % 8);
if byte_idx < buf.len() {
buf[byte_idx] |= 1 << bit_idx;
}
}
}

fn write_bits(buf: &mut [u8], bit_offset: usize, num_bits: usize, value: u64) {
for i in 0..num_bits {
let bit = (value >> (num_bits - 1 - i)) & 1 == 1;
write_bit(buf, bit_offset + i, bit);
}
}

fn encode_tc_string(vendor_count: u16, use_range: bool) -> String {
let bytes = build_tc_bytes(vendor_count, use_range);
URL_SAFE_NO_PAD.encode(&bytes)
}

// ---------------------------------------------------------------------------
// Benchmarks
// ---------------------------------------------------------------------------

fn bench_us_privacy(c: &mut Criterion) {
c.bench_function("us_privacy_decode", |b| {
b.iter(|| decode_us_privacy(black_box("1YNN")));
});
}

fn bench_tcf_decode(c: &mut Criterion) {
let small_tc = encode_tc_string(10, false);
let medium_tc = encode_tc_string(100, false);
let large_tc_bitfield = encode_tc_string(500, false);
let large_tc_range = encode_tc_string(500, true);

let mut group = c.benchmark_group("tcf_decode");

group.bench_with_input(
BenchmarkId::new("bitfield", "10_vendors"),
&small_tc,
|b, tc| {
b.iter(|| decode_tc_string(black_box(tc)));
},
);

group.bench_with_input(
BenchmarkId::new("bitfield", "100_vendors"),
&medium_tc,
|b, tc| {
b.iter(|| decode_tc_string(black_box(tc)));
},
);

group.bench_with_input(
BenchmarkId::new("bitfield", "500_vendors"),
&large_tc_bitfield,
|b, tc| {
b.iter(|| decode_tc_string(black_box(tc)));
},
);

group.bench_with_input(
BenchmarkId::new("range", "500_vendors"),
&large_tc_range,
|b, tc| {
b.iter(|| decode_tc_string(black_box(tc)));
},
);

group.finish();
}

fn bench_gpp_decode(c: &mut Criterion) {
let mut group = c.benchmark_group("gpp_decode");

group.bench_function("usp_only", |b| {
b.iter(|| gpp::decode_gpp_string(black_box(GPP_USP_ONLY)));
});

group.bench_function("with_tcf", |b| {
b.iter(|| gpp::decode_gpp_string(black_box(GPP_TCF_AND_USP)));
});

group.finish();
}

fn bench_full_pipeline(c: &mut Criterion) {
// Build a realistic TC string (500 vendors, range encoding)
let tc_string = encode_tc_string(500, true);

let all_signals = RawConsentSignals {
raw_tc_string: Some(tc_string),
raw_gpp_string: Some(GPP_USP_ONLY.to_owned()),
raw_gpp_sid: Some("6".to_owned()),
raw_us_privacy: Some("1YNN".to_owned()),
gpc: true,
};

let empty_signals = RawConsentSignals::default();

let tc_only = RawConsentSignals {
raw_tc_string: Some(encode_tc_string(500, true)),
..Default::default()
};

let mut group = c.benchmark_group("full_pipeline");

group.bench_function("all_signals", |b| {
b.iter(|| build_context_from_signals(black_box(&all_signals)));
});

group.bench_function("empty_signals", |b| {
b.iter(|| build_context_from_signals(black_box(&empty_signals)));
});

group.bench_function("tcf_only", |b| {
b.iter(|| build_context_from_signals(black_box(&tc_only)));
});

group.finish();
}

criterion_group!(
benches,
bench_us_privacy,
bench_tcf_decode,
bench_gpp_decode,
bench_full_pipeline,
);
criterion_main!(benches);
8 changes: 7 additions & 1 deletion crates/common/build.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
#![allow(clippy::unwrap_used, clippy::panic)]
// Build script includes source modules (`error`, `auction_config_types`, etc.)
// for compile-time config validation. Not all items from those modules are used
// in the build context, so `dead_code` is expected.
#![allow(clippy::unwrap_used, clippy::panic, dead_code)]

#[path = "src/error.rs"]
mod error;

#[path = "src/auction_config_types.rs"]
mod auction_config_types;

#[path = "src/consent_config.rs"]
mod consent_config;

#[path = "src/settings.rs"]
mod settings;

Expand Down
26 changes: 25 additions & 1 deletion crates/common/src/auction/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ use error_stack::{Report, ResultExt};
use fastly::{Request, Response};

use crate::auction::formats::AdRequest;
use crate::consent;
use crate::cookies::handle_request_cookies;
use crate::error::TrustedServerError;
use crate::geo::GeoInfo;
use crate::settings::Settings;
use crate::synthetic::get_or_generate_synthetic_id;

use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request};
use super::types::AuctionContext;
Expand Down Expand Up @@ -41,8 +45,28 @@ pub async fn handle_auction(
body.ad_units.len()
);

// Generate synthetic ID early so the consent pipeline can use it for
// KV Store fallback/write operations.
let synthetic_id = get_or_generate_synthetic_id(settings, &req).change_context(
TrustedServerError::Auction {
message: "Failed to generate synthetic ID".to_string(),
},
)?;

// Extract consent from request cookies, headers, and geo.
let cookie_jar = handle_request_cookies(&req)?;
let geo = GeoInfo::from_request(&req);
let consent_context = consent::build_consent_context(&consent::ConsentPipelineInput {
jar: cookie_jar.as_ref(),
req: &req,
config: &settings.consent,
geo: geo.as_ref(),
synthetic_id: Some(synthetic_id.as_str()),
});

// Convert tsjs request format to auction request
let auction_request = convert_tsjs_to_auction_request(&body, settings, &req)?;
let auction_request =
convert_tsjs_to_auction_request(&body, settings, &req, consent_context, &synthetic_id)?;

// Create auction context
let context = AuctionContext {
Expand Down
26 changes: 16 additions & 10 deletions crates/common/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ use std::collections::HashMap;
use uuid::Uuid;

use crate::auction::context::ContextValue;
use crate::consent::ConsentContext;
use crate::creative;
use crate::error::TrustedServerError;
use crate::geo::GeoInfo;
use crate::openrtb::{OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid};
use crate::settings::Settings;
use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id};
use crate::synthetic::generate_synthetic_id;

use super::orchestrator::OrchestrationResult;
use super::types::{
Expand Down Expand Up @@ -63,24 +64,29 @@ pub struct BannerUnit {
pub sizes: Vec<Vec<u32>>,
}

/// Convert tsjs/Prebid.js request format to internal `AuctionRequest`.
/// Convert tsjs/Prebid.js request format to internal [`AuctionRequest`].
///
/// The `consent` parameter carries decoded consent signals extracted from the
/// incoming request's cookies and headers. It is populated by the caller
/// (the `/auction` endpoint handler) and forwarded through to the
/// [`OpenRTB`][`crate::openrtb::OpenRtbRequest`] bid request.
///
/// The `synthetic_id` is generated by the caller before the consent pipeline
/// runs, so that KV Store operations can use it as a key.
///
/// # Errors
///
/// Returns an error if:
/// - Synthetic ID generation fails
/// - Fresh synthetic ID generation fails
/// - Request contains invalid banner sizes (must be [width, height])
pub fn convert_tsjs_to_auction_request(
body: &AdRequest,
settings: &Settings,
req: &Request,
consent: ConsentContext,
synthetic_id: &str,
) -> Result<AuctionRequest, Report<TrustedServerError>> {
// Generate synthetic ID
let synthetic_id = get_or_generate_synthetic_id(settings, req).change_context(
TrustedServerError::Auction {
message: "Failed to generate synthetic ID".to_string(),
},
)?;
let synthetic_id = synthetic_id.to_owned();
let fresh_id =
generate_synthetic_id(settings, req).change_context(TrustedServerError::Auction {
message: "Failed to generate fresh ID".to_string(),
Expand Down Expand Up @@ -179,7 +185,7 @@ pub fn convert_tsjs_to_auction_request(
user: UserInfo {
id: synthetic_id,
fresh_id,
consent: None,
consent: Some(consent),
},
device,
site: Some(SiteInfo {
Expand Down
Loading