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
317 changes: 316 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.28"
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 = ["plotters", "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);
5 changes: 4 additions & 1 deletion crates/common/build.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
#![allow(clippy::unwrap_used, clippy::panic)]
#![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
16 changes: 15 additions & 1 deletion crates/common/src/auction/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ 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 super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request};
Expand Down Expand Up @@ -41,8 +44,19 @@ pub async fn handle_auction(
body.ad_units.len()
);

// 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: None, // Auction requests don't carry a Synthetic ID yet.
});

// 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)?;

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

use crate::auction::types::OrchestratorExt;
use crate::consent::ConsentContext;
use crate::creative;
use crate::error::TrustedServerError;
use crate::geo::GeoInfo;
Expand Down Expand Up @@ -63,7 +64,12 @@ 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.
///
/// # Errors
///
Expand All @@ -74,6 +80,7 @@ pub fn convert_tsjs_to_auction_request(
body: &AdRequest,
settings: &Settings,
req: &Request,
consent: ConsentContext,
) -> Result<AuctionRequest, Report<TrustedServerError>> {
// Generate synthetic ID
let synthetic_id = get_or_generate_synthetic_id(settings, req).change_context(
Expand Down Expand Up @@ -145,7 +152,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
10 changes: 8 additions & 2 deletions crates/common/src/auction/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,14 @@ pub struct UserInfo {
pub id: String,
/// Fresh ID for this session
pub fresh_id: String,
/// GDPR consent string if applicable
pub consent: Option<String>,
/// Decoded consent context for this request.
///
/// Carries both raw consent strings (for `OpenRTB` forwarding) and decoded
/// structured data (for TS-level enforcement and observability).
/// Skipped during serde since it is populated at runtime from request
/// cookies/headers, not from stored data.
#[serde(skip)]
pub consent: Option<crate::consent::ConsentContext>,
}

/// Device information from request.
Expand Down
Loading